From 8ac9cd89fa8e7e90bc7ac2e4b640c78bd9dcca28 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 17:44:34 +0000 Subject: [PATCH 1/7] validate-family.py: a hand mount's `git rm` line works typed: `git rm --cached` where a plain one refuses a staged gitlink, dies on a clone no section names or refuses what is no checkout, with what it leaves said, and a `.gitmodules` gone from the work tree put back before the `git rm` that takes a section out (#936) Every `git rm` line `member-gitlink-unnamed` prints for a hand mount now works typed, and the gate passes after the commit. Based on main, where #889 landed as 702cc23d. - `_hand_cached`: asked of git's records and what is on disk, as git's rm.c asks it, never of git's words. The line is `git rm --cached`, with `-f` where `_forced` says, which touches no file, where a plain `git rm` would - refuse a gitlink the index stages at a commit HEAD does not record there, over anything on disk (exit 1, either of git's two refusals, both quoted: `STAGED_AT`); - die on a clone whose `.git` is a directory that no section of the file git reads names (could not lookup name for submodule, exit 128); - refuse what is on disk and is no checkout (exit 1). `_cached_left` says what stays on disk and what to do with it before `family.py add`: move a clone out whole, delete a checkout once nothing only it holds is needed. Where HEAD records nothing at the path, nothing of it is left to commit. - `_check_gitmodules_named`: a section at such a gitlink's path prints its own `--unset-all` lines, which `--cached` leaves to it. - `_plain_rm_said`: where the work tree's `.gitmodules` is gone and the file git reads gives the hand mount's path, the file is put back first, from where git reads it, unless a finding before did, so the `git rm` takes the section out and a staged removal's put-back after it no longer brings it back. - The controls print the plain `git rm`, as before. Tests: tests/test_family_hand_mount_git_rm_lines.py (17 rows); #889's round-2 SHOULD test in tests/test_family_holder_remedies_compose.py is dropped, its states being rows here, and its helpers stay there. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- .../family-root/scripts/validate-family.py | 240 ++++++++-- tests/test_family_hand_mount_git_rm_lines.py | 425 ++++++++++++++++++ tests/test_family_holder_remedies_compose.py | 81 +--- 3 files changed, 634 insertions(+), 112 deletions(-) create mode 100644 tests/test_family_hand_mount_git_rm_lines.py diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index 49dc35b2..25475c88 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -2533,7 +2533,11 @@ def _check_gitlinks_named(root: Path, gitlinks: dict[str, str], A hand mount's plain `git rm` takes its section out of the work tree's `.gitmodules`, as the move's lines write one (`_move_section`): each is recorded as a write (`Report.wrote`), so a staged removal's finding - after it puts no file back over it (lane 1's SHOULD 1 on #889). + after it puts no file back over it (lane 1's SHOULD 1 on #889). Where + a plain `git rm` dies or refuses for a reason other than changes in + the checkout, the line is `git rm --cached`, and where the work tree's + `.gitmodules` is gone, it is put back before the `git rm` that takes + a section out of it (`_mounted_by_hand`, #936). """ unnamed = [path for path in sorted(gitlinks) if path not in named and _in_members(path)] @@ -2558,8 +2562,7 @@ def _check_gitlinks_named(root: Path, gitlinks: dict[str, str], "member-gitlink-unnamed", f"{said} " + _mounted_by_hand( root, path, _hand_project(path), path in removed, stage, - _staged_over(root, path, gitlinks[path]))) - report.wrote = report.wrote or path not in removed + gitlinks[path], report)) def _gitlink_unnamed_said(path: str, commit: str, fetched: bool) -> str: @@ -2801,21 +2804,110 @@ def _given_as_git_reads(root: Path) -> set[str]: return _blob_paths(root, spec) if spec else set() -#: What a hand mount's text says its `git rm` meets where the index stages -#: the gitlink at a commit HEAD does not record there (`_mounted_by_hand`, -#: lane 1's SHOULD on #889, round 2): git's two refusals, each exit 1, the +#: Why a plain `git rm` of a hand mount's gitlink refuses it where the +#: index stages it at a commit HEAD does not record there, with something +#: on disk at it (`_staged_over`): git's two refusals, each exit 1, the #: second where what is on disk differs from what is staged too, as git -#: 2.43 gave them (`_staged_over`; lane 1's NIT 1 on #889, round 3). -STAGED_AT = (", and while the index stages it at a commit HEAD does not " - "record there, as it does here (the following file has changes " +#: 2.43 gave them (lane 1's SHOULD on #889, round 2, and its NIT 1, round +#: 3), said where its text takes the gitlink out of the index alone (#936). +STAGED_AT = ("the index stages it at a commit HEAD does not record there, " + "with something on disk at it (the following file has changes " "staged in the index, or, where what is on disk differs too, " "has staged content different from both the file and the HEAD, " - "exit 1),") + "exit 1)") + +#: Why a hand mount's text takes its gitlink out of the index alone, where +#: a plain `git rm` of it dies or refuses for a reason other than changes +#: in its checkout (`_hand_cached`, #936), as git 2.43 was measured to. +CACHED_WHY = { + "clone": (f"its .git is a directory and no section of {GITMODULES} " + "gives its path, so git rm cannot move that directory into " + "this holder's store first (could not lookup name for " + "submodule, exit 128)"), + "staged": STAGED_AT, + "no checkout": ("what is on disk there is no checkout (the following " + "file has local modifications, exit 1)"), +} + + +def _hand_cached(root: Path, path: str, commit: str) -> str | None: + """Why the hand mount's `git rm` takes the gitlink at `path`, which + `_member_gitlinks` reads at `commit`, out of the index alone, a key + of `CACHED_WHY`, or None where a plain `git rm -- ` takes it, + refusing only while its checkout holds changes (#936). + + Asked as git's rm.c asks it, of git's own records and of what is on + disk, and never of its words: + - "clone": a `.git` DIRECTORY at `path`, a clone of its own, which a + plain `git rm` first moves into this holder's store under the name + a section of the file git reads gives the path + (`submodules_absorb_gitdir_if_needed`); where none gives it, git + dies there (`could not lookup name for submodule`, exit 128), as + after `git clone` and `git add`; + - "staged": the index stages it at a commit HEAD does not record + there and anything is on disk at it (`_staged_over`), as after + `git mv members/

members/

2`, staged, a `git submodule add` + not committed, or a checkout moved to another commit and `git + add`-ed: git refuses it, exit 1, in one of two words (`STAGED_AT`), + and `-f` would take the checkout's changes with it; + - "no checkout": what is on disk at it holds no `.git` entry and is + no empty directory (`_on_disk`), a directory holding files or a + file: git refuses it as modified (exit 1). + `git rm --cached` takes each out and touches no file, with `-f` where + `_forced` says, and what it leaves is said with what to do with it + (`_cached_left`); the `.gitmodules` section it leaves, where one gives + the path, `gitmodules-path-unnamed` takes out by key + (`_check_gitmodules_named`).""" + git = root / path / ".git" + if git.is_dir() and path not in _given_as_git_reads(root): + return "clone" + if _staged_over(root, path, commit): + return "staged" + if _on_disk(root, path) and not os.path.lexists(git): + return "no checkout" + return None + + +#: What the text says before the add of what is on disk at a path whose +#: gitlink `git rm --cached` took out: `family.py add` refuses anything +#: there (`member-path-taken`). +ADD_REFUSES = ("before the add, which refuses anything at {path} " + "(member-path-taken).") + + +def _cached_left(root: Path, path: str) -> str: + """What a hand mount's text says of what its `git rm --cached` leaves + at `path`, untracked, and what to do with it (#936): a clone of its + own holds its commits there and nowhere else, so it is moved out + whole, as `member-gitlink-removal-staged` says of one (`_moved_said`); + a checkout whose `.git` file names a store keeps its commits there, so + only what is not committed is lost with it; anything else is moved or + deleted, and an empty directory deleted.""" + git = root / path / ".git" + then = ADD_REFUSES.format(path=path) + if git.is_dir(): + return (f" The clone at {path}, whose .git is a directory, stays " + "there, untracked: it holds its commits there and nowhere " + f"else, and {_unpushed([path])} lists the ones not pushed, " + "so move it out of the holder whole, by hand, rather than " + f"delete it, {then}") + if os.path.lexists(git): + return (f" The checkout at {path} stays there, untracked: its .git " + "file names the store that keeps its commits whatever " + "becomes of the checkout, so only what is not committed is " + f"in the checkout alone, which {_statuses([path])} shows. " + "Delete it by hand once nothing only it holds is needed, " + f"{then}") + if _on_disk(root, path): + return (f" What is on disk at {path}, which is no checkout, stays " + "there, untracked: move it out of the holder, or delete it " + f"by hand once nothing in it is needed, {then}") + return f" The empty directory at {path} stays there: delete it {then}" def _mounted_by_hand(root: Path, path: str, project: str | None, staged: bool, stage_first: bool = False, - staged_at: bool = False) -> str: + commit: str = "", report: Report | None = None) -> str: """What a gitlink no row names, in no case, says to do after what it is: put its row back, or take a hand mount out, commit that, and mount it with `family.py add`, on a branch (`_check_gitlinks_named`). Where its @@ -2824,12 +2916,27 @@ def _mounted_by_hand(root: Path, path: str, project: str | None, refuses (`member-path-taken`), deleted by hand first (rv681's S1). Its `git rm` comes after `git add -- .gitmodules` where `stage_first` (`_stage_first`): after a value finding's lines it exited 128 (#879's - C3). Where that `git rm` refuses the gitlink as staged (`staged_at`, - as `_staged_over` reads it), as after `git mv members/

- members/

2`, staged, the text says so (`STAGED_AT`), where it said - only that the line refuses while the checkout holds changes (lane 1's - SHOULD on #889, round 2). No line here takes such a gitlink out: - `-f` would take the checkout's changes with it.""" + C3). + + EVERY LINE WORKS TYPED (#936). Where a plain `git rm` of the gitlink, + which `_member_gitlinks` reads at `commit`, dies or refuses for a + reason other than changes in its checkout (`_hand_cached`), the line + is `git rm --cached`, with `-f` where `_forced` says, which touches no + file: it said `git rm --` there, which exited 1 or 128, and the text + said why after lane 1's SHOULD on #889 and printed no line that + worked. Where HEAD records nothing at the path, nothing of it is left + to commit. What it leaves on disk is said, with what to do with it + (`_cached_left`). + + A `.gitmodules` GONE FROM THE WORK TREE, where the file git reads in + its place gives the path, is put back first, from where git reads it, + unless a finding printed before did (`_file_put_back`, recorded in + `report`): the plain `git rm` takes the section out of the work tree's + file alone, so with none there it took nothing out, and the staged + removal's put-back after it brought the section back, which the gate + then found (`gitmodules-path-unnamed`, #936). The plain line takes the + section out and is recorded as a write (`Report.wrote`, lane 1's + SHOULD 1 on #889); `--cached` writes nothing.""" member = (PROJECT_PLACEHOLDER if project is None else shlex.quote(project)) add = (f" {PYTHON} scripts/family.py add --family-root " @@ -2850,27 +2957,83 @@ def _mounted_by_hand(root: Path, path: str, project: str | None, "one commit, and names the object store git rm leaves under " f".git/modules if one is still there. {BRANCH_LEFT}\n" f" {_switch_line(root, 'add', project)}\n{add}") - staging, rm_at, add_at = ((f"stage {GITMODULES} with the second, " - f"{STAGE_TOO}, {STAGE_WHY}, ", "third", - "fourth") - if stage_first else ("", "second", "third")) + why = _hand_cached(root, path, commit) + lines = [_switch_line(root, "add", project)] + said = (_plain_rm_said(root, path, stage_first, report, lines) + if why is None else + _cached_rm_said(root, path, commit, why, stage_first, lines)) return ( f"{RESTORE_ROW} If it was mounted by hand, make a branch " "with the first command below, since family.py add refuses the " - f"holder's tracking branch, {staging}take it out with the " - f"{rm_at}, which refuses while its checkout holds changes" - + (STAGED_AT if staged_at else "") - + f" and takes its {GITMODULES} section out too, and commit that " - "removal, since " - "family.py add refuses a path under another spelling of members/ " - "that HEAD holds and the index no longer does, then mount it " - f"with the {add_at}, from a checkout of openRepoShape: family.py " - "add writes its row and its gitlink in one commit, and names the " - "object store git rm leaves under .git/modules if one is still " - f"there. {BRANCH_LEFT}\n" - f" {_switch_line(root, 'add', project)}\n" - + (f" {STAGE_LINE}\n" if stage_first else "") - + f" git rm -- {shlex.quote(path)}\n{add}") + f"holder's tracking branch, {said}, then mount it with the " + f"{_ordinal(len(lines))}, from a checkout of openRepoShape: " + "family.py add writes its row and its gitlink in one commit, and " + "names the object store git rm leaves under .git/modules if one is " + f"still there.{_cached_left(root, path) if why else ''} " + f"{BRANCH_LEFT}\n" + + "".join(f" {line}\n" for line in lines) + add) + + +#: Why the removal of a hand mount is committed before `family.py add`. +COMMIT_WHY = ("since family.py add refuses a path under another spelling of " + "members/ that HEAD holds and the index no longer does") + + +def _staging_said(stage_first: bool, lines: list[str]) -> str: + """What the text says of `git add -- .gitmodules`, appended to `lines`, + where `stage_first` (#879's C3), else nothing.""" + if not stage_first: + return "" + lines.append(STAGE_LINE) + return (f"stage {GITMODULES} with the {_ordinal(len(lines) - 1)}, " + f"{STAGE_TOO}, {STAGE_WHY}, ") + + +def _plain_rm_said(root: Path, path: str, stage_first: bool, + report: Report | None, lines: list[str]) -> str: + """What the text says of a hand mount's plain `git rm`, from "take it + out" to "commit that removal", its lines appended to `lines`: the work + tree's `.gitmodules` put back first where it is gone and the file git + reads in its place gives `path` (`_file_put_back`, #936).""" + put = "" + if (not os.path.lexists(root / GITMODULES) + and path in _given_as_git_reads(root)): + line, whose = _file_put_back(root, report) + if line: + lines.append(line) + put = (f"put {GITMODULES}, gone from the work tree, back from " + f"{whose} with the {_ordinal(len(lines) - 1)}, so that " + "the git rm takes its section out of that file and every " + "other section stays, ") + staging = _staging_said(stage_first, lines) + lines.append(f"git rm -- {shlex.quote(path)}") + if report is not None: + report.wrote = True + return (f"{put}{staging}take it out with the " + f"{_ordinal(len(lines) - 1)}, which refuses while its checkout " + f"holds changes and takes its {GITMODULES} section out too, and " + f"commit that removal, {COMMIT_WHY}") + + +def _cached_rm_said(root: Path, path: str, commit: str, why: str, + stage_first: bool, lines: list[str]) -> str: + """What the text says of a hand mount's `git rm --cached` (#936), + from "take it out" to the commit, its lines appended to `lines`: why + a plain `git rm` is not printed (`CACHED_WHY`), `-f` where `_forced` + says, with what it does (`FORCED_SAID`), and no commit of it where + HEAD records nothing at `path`.""" + staging = _staging_said(stage_first, lines) + head = _forced(root, path, commit) + lines.append(_cached_removal(path, head)) + forced = "" if head is None else f" ({FORCED_SAID})" + commit_said = (", and commit that removal, " + COMMIT_WHY + if _head_records(root, path) else + "; HEAD records nothing there, so nothing of it is left " + "to commit") + return (f"{staging}take it out of the index alone with the " + f"{_ordinal(len(lines) - 1)}, which touches no file{forced}, " + f"since a plain git rm refuses it here: {CACHED_WHY[why]}" + f"{commit_said}") def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], @@ -2897,7 +3060,9 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], A section at the path of a gitlink whose finding says to `git rm` it prints no command: that `git rm` takes the section out too, and a - `--unset-all` run after it exits 5, the key being gone. A gitlink whose + `--unset-all` run after it exits 5, the key being gone. Where that + finding's line is `git rm --cached` (`_hand_cached`, #936), which + leaves the section, the section prints its own lines. A gitlink whose removal is staged (`removed`, #681) prints no `git rm`, so its section, which `git rm --cached` leaves, keeps its own commands. Where two sections give the path (`gitmodules-path-twice`) it takes out only the @@ -2945,7 +3110,8 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], said += _variant_said(*variant) + _section_out( name, keys, "Take", f"then commit {GITMODULES}") elif (path in gitlinks and path not in removed and given[path] == 1 - and _in_members(path)): + and _in_members(path) + and _hand_cached(root, path, gitlinks[path]) is None): report.finding( "gitmodules-path-unnamed", f"{said}{SECTION_ROW_BACK} Otherwise the git rm printed above " diff --git a/tests/test_family_hand_mount_git_rm_lines.py b/tests/test_family_hand_mount_git_rm_lines.py new file mode 100644 index 00000000..87da3382 --- /dev/null +++ b/tests/test_family_hand_mount_git_rm_lines.py @@ -0,0 +1,425 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The family holder's pinned `scripts/validate-family.py`: the `git rm` +line `member-gitlink-unnamed` prints for a member mounted by hand works +typed, and the gate after the commit passes (#936). + +THE ISSUE'S THREE STATES, each measured on #889's head (29083a5f) before +this change, every printed line typed as printed: + +1. A gitlink the index stages at a commit HEAD does not record there, with + something on disk at it: `git mv members/IRRS members/IRRS2`, staged, + clean or with a commit in the checkout it moved; a gitlink staged over + an empty directory or a directory holding a file; a `git submodule add` + not committed; a checkout moved to `HEAD~1` and `git add`-ed. The + printed `git rm -- ` exited 1 in each (`the following file has + changes staged in the index`, or, where what is on disk differs from + what is staged too, `... has staged content different from both the + file and the HEAD`), and the gate found the gitlink again. `-f` would + take the checkout's changes with it. The line is `git rm --cached` now, + with `-f` over anything but an empty directory, which touches no file, + and the text says why, quoting both refusals, what it leaves on disk + and what to do with it. +2. A hand mount with its own section, then a staged removal of a member, + with the work tree's `.gitmodules` deleted: the `git rm` took the + section out of no file, the removal's put-back brought it back, and the + gate found `gitmodules-path-unnamed` after the commit. The file is put + back first now, from where git reads it, and the `git rm` takes the + section out of it. +3. A member cloned by hand into `members/IRQS`, its `.git` a directory, + then `git add`, with no section giving the path: the printed `git rm -- + members/IRQS` died (`could not lookup name for submodule`, exit 128). + The line is `git rm --cached` now, and the clone, which holds its + commits there and nowhere else, is said to be moved out whole. + +BEYOND THE ISSUE, its goal (every hand mount's `git rm` works typed): a +directory holding files and no `.git`, or a file, at a committed hand +mount's path, where the plain line exited 1 (`the following file has local +modifications`). It is `git rm --cached` too. + +THE CONTROLS: a hand mount with nothing on disk, one with its checkout and +its section, and a clone a section names, which a plain `git rm` moves into +the holder's store first: each still prints the plain `git rm`, which works. + +Each row types every `git` line as printed, in the order printed, split as +a POSIX shell splits it and run with no shell (not `family.py add`, which +mounts a hand mount again only where the person wants it), deals by hand +with what the text says to delete or move out, commits, then asks the gate +in every mode, and a plain clone's `git submodule update --init +--recursive` must check out both members. The helpers are #879's +(`tests/test_family_holder_remedies_compose.py`) and those it imports. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from conftest import rmtree +from test_family import SPARE, family, validate # noqa: F401 +from test_family_add_refusals import holder, offline # noqa: F401 +from test_family_bootstrap_gate_order import hand_mounted, type_line +from test_family_head_lost_gitmodules import ( # noqa: F401 + commit_all, staged_and_committed, +) +from test_family_holder_remedies_compose import ( + MODES, OTHER_ROW, a_file_at, cloning, committed_with_its_checkout, + ends_well, files_at, found_alike, head_paths, holding_a_file, + moved_back_and_added, moved_by_git_mv, moved_with_a_commit, + staged_by_hand, typed_in_order, +) +from test_family_member_path import raw +from test_family_unnamed_gitlink import submodule_added +from test_validate_family_typed_states import GONE, ROW +from test_validate_family_unnamed_mounts import commands + +HAND = f"members/{SPARE}" + +#: What a hand mount's text says of a `git rm --cached` it prints (#936), +#: by why a plain `git rm` is not printed. A holder's CI matches on text, so +#: each is pinned here and not rebuilt from the gate's own strings. +CACHED_SAID = ("take it out of the index alone with the second, which " + "touches no file{forced}, since a plain git rm refuses it " + "here: ") +FORCED = (" (-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)") +STAGED_WHY = ("the index stages it at a commit HEAD does not record there, " + "with something on disk at it (the following file has changes " + "staged in the index, or, where what is on disk differs too, " + "has staged content different from both the file and the " + "HEAD, exit 1)") +CLONE_WHY = ("its .git is a directory and no section of .gitmodules gives " + "its path, so git rm cannot move that directory into this " + "holder's store first (could not lookup name for submodule, " + "exit 128)") +NO_CHECKOUT_WHY = ("what is on disk there is no checkout (the following file " + "has local modifications, exit 1)") +#: What it says of the commit, where HEAD records the gitlink, and not. +COMMIT_THAT = (", and commit that removal, since family.py add refuses a " + "path under another spelling of members/ that HEAD holds and " + "the index no longer does, then mount it with the third") +NOTHING_LEFT = ("; HEAD records nothing there, so nothing of it is left to " + "commit, then mount it with the third") +#: What it says is left at `{path}`, untracked, and what to do with it. +ADD_REFUSES = ("before the add, which refuses anything at {path} " + "(member-path-taken).") +CHECKOUT_LEFT = ( + " The checkout at {path} stays there, untracked: its .git file names " + "the store that keeps its commits whatever becomes of the checkout, so " + "only what is not committed is in the checkout alone, which `git -C " + "{path} status` shows. Delete it by hand once nothing only it holds is " + "needed, " + ADD_REFUSES) +EMPTY_LEFT = (" The empty directory at {path} stays there: delete it " + + ADD_REFUSES) +CLONE_LEFT = ( + " The clone at {path}, whose .git is a directory, stays there, " + "untracked: it holds its commits there and nowhere else, and `git -C " + "{path} log --oneline HEAD --branches --not --remotes` lists the ones " + "not pushed, so move it out of the holder whole, by hand, rather than " + "delete it, " + ADD_REFUSES) +NO_CHECKOUT_LEFT = ( + " What is on disk at {path}, which is no checkout, stays there, " + "untracked: move it out of the holder, or delete it by hand once " + "nothing in it is needed, " + ADD_REFUSES) +#: What the plain line says, as before this change. +PLAIN_SAID = ("take it out with the second, which refuses while its " + "checkout holds changes and takes its .gitmodules section out " + "too, and commit that removal") + + +def mount_of(found: list[str], path: str) -> str: + """The hand mount's finding for `path` among `found`.""" + head = f"FINDING member-gitlink-unnamed: {path} " + mounts = [finding for finding in found if finding.startswith(head)] + assert len(mounts) == 1, found + return mounts[0] + + +def typed_around(root: Path, env: dict, found: list[str], dealt) -> None: + """Every `git` line `found` prints, typed as printed, in the order + printed, but each `git submodule update`, typed after `dealt()` has + dealt by hand with what the text says to delete or move out first: + the update points a moved checkout's store back at the row's path + (#879's C4).""" + later = [] + for finding in found: + for line in commands(finding): + if line.startswith("git submodule update"): + later.append(line) + elif line.startswith("git "): + type_line(root, env, line) + dealt() + for line in later: + type_line(root, env, line) + + +def committed_if_staged(root: Path, env: dict) -> None: + """`.gitmodules` staged, as the section's lines say, then a commit of + what is staged, where anything is: after a gitlink HEAD never recorded + is taken out, nothing of it is left to commit.""" + raw(root, env, "add", "--", ".gitmodules") + if raw(root, env, "diff", "--cached", "--quiet", + check=False).returncode: + commit_all(root, env, "Typed in order") + + +def deleted(where: Path) -> None: + """What is at `where` deleted by hand, a directory or a file.""" + if where.is_dir() and not where.is_symlink(): + rmtree(where) + else: + where.unlink() + + +# --- 1. a gitlink staged at a commit HEAD does not record ------------------ + +def added_not_committed(root: Path, env: dict, family) -> str: + """`git submodule add` at `members/IRQS`, not committed: its checkout and + its section, staged. Returns the path.""" + submodule_added(root, env, family, HAND) + return HAND + + +#: `(the state, the line's flag, whether HEAD records the gitlink, what is +#: left on disk)`. Typed, a plain `git rm` said `has changes staged in the +#: index` in the first four, and `has staged content different from both +#: the file and the HEAD` in the last two, whose disk differs from what is +#: staged (lane 1's NIT 1 on #889, round 3). +STAGED = { + "git-mv": (moved_by_git_mv, " -f", False, CHECKOUT_LEFT), + "an-empty-directory": (staged_by_hand(Path.mkdir), "", False, + EMPTY_LEFT), + "submodule-add-not-committed": (added_not_committed, " -f", False, + CHECKOUT_LEFT), + "head-records-another-commit": (moved_back_and_added, " -f", True, + CHECKOUT_LEFT), + "git-mv-and-a-commit-in-it": (moved_with_a_commit, " -f", False, + CHECKOUT_LEFT), + "a-directory-holding-a-file": (staged_by_hand(holding_a_file), " -f", + False, NO_CHECKOUT_LEFT), +} + + +@pytest.mark.parametrize("make, flag, in_head, left", STAGED.values(), + ids=STAGED) +def test_issue_1_a_gitlink_staged_over_what_is_on_disk_comes_out_cached( + family, holder, offline, tmp_path, make, flag, in_head, left): + """The issue's state 1 (lane 1's SHOULD on #889): the index stages the + gitlink at a commit HEAD does not record there, with something on disk + at it. The printed `git rm -- ` exited 1, with either of git's + two refusals. It is `git rm --cached` now, in every mode, `-f` over + anything but an empty directory, and the text says why, quoting both + refusals, what is left, and that nothing is left to commit where HEAD + records nothing. + A section that gives the path prints its own lines, which `--cached` + leaves to it. Typed in order, what is left deleted by hand as the text + says, then committed: nothing is left, the gate passes in every mode + and a plain clone checks both members out.""" + env = cloning(offline, tmp_path, family) + path = make(holder, env, family) + + found = found_alike(holder, *MODES) + + mount = mount_of(found, path) + assert commands(mount)[1] == f"git rm --cached{flag} -- {path}" + assert (CACHED_SAID.format(forced=FORCED if flag else "") + STAGED_WHY + + (COMMIT_THAT if in_head else NOTHING_LEFT)) in mount, mount + assert left.format(path=path) in mount, mount + typed_around(holder, env, found, lambda: deleted(holder / path)) + committed_if_staged(holder, env) + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +# --- 2. a hand mount's section, with the work tree's file gone ------------ + +#: The flags of the removal of MEMBER, by how it was staged. +REMOVED = {"cached": ["--cached"], "plain": []} + + +def gone_from(root: Path, env: dict, how: str) -> None: + """The work tree's `.gitmodules` deleted, or its deletion staged too, as + `how` (`GONE`) says: by `git rm --cached` and the file deleted, since + after a plain `git rm` of a member the plain `git rm` of the file + refuses its staged change.""" + if how != "deleted": + raw(root, env, "rm", "-q", "--cached", "--", ".gitmodules") + (root / ".gitmodules").unlink() + + +def removal_lines(kind: str, how: str, url: str) -> list[str]: + """What the removal's finding prints after the hand mount's put-back: + its gitlink, and after a plain `git rm` its checkout, with its section + written back key by key where the file put back, the index's, lacks + it, which HEAD's does not.""" + if kind == "cached": + return [f"git reset -q -- {ROW}"] + section = ([f"git config -f .gitmodules submodule.{ROW}.path {ROW}", + f"git config -f .gitmodules submodule.{ROW}.url {url}", + "git add -- .gitmodules"] if how == "deleted" else []) + return [f"git reset -q -- {ROW}", *section, + f"git submodule update --init --recursive -- {ROW}"] + + +@pytest.mark.parametrize("how", GONE) +@pytest.mark.parametrize("kind", REMOVED) +def test_issue_2_a_hand_mount_s_section_comes_out_of_the_file_put_back( + family, holder, offline, tmp_path, kind, how): + """The issue's state 2: a hand mount with its checkout and its section, + committed, then `git rm --cached members/IRRS` or a plain `git rm + members/IRRS`, and the work tree's `.gitmodules` deleted or its + deletion staged. The hand mount's `git rm` took its section out of no + file, the removal's put-back brought it back, and the gate found + `gitmodules-path-unnamed` after the commit. The hand mount puts the + file back first now, from where git reads it, and the removal after it + puts nothing back, writing MEMBER's section back key by key where the + file put back lacks it. Typed in order and committed, nothing is left, + HEAD's `.gitmodules` gives both members, the gate passes in every mode + and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + path = committed_with_its_checkout(holder, env, family) + url = raw(holder, env, "config", "-f", ".gitmodules", + f"submodule.{ROW}.url").stdout.decode("utf-8").strip() + raw(holder, env, "rm", "-q", *REMOVED[kind], "--", ROW) + gone_from(holder, env, how) + + found = found_alike(holder, *MODES) + + whose, put_back = GONE[how] + mount = mount_of(found, path) + assert commands(mount)[1:3] == [put_back, f"git rm -- {path}"] + assert (f"put .gitmodules, gone from the work tree, back from {whose} " + "with the second, so that the git rm takes its section out of " + "that file and every other section stays, take it out with the " + "third, which refuses while its checkout holds changes") in mount + assert commands(found[-1]) == removal_lines(kind, how, url) + typed_in_order(holder, env, validate(holder, "--no-members")) + staged_and_committed(holder, env, "Typed in order") + assert raw(holder, env, "status", "--porcelain").stdout == b"" + assert head_paths(holder, env) == {ROW, OTHER_ROW} + ends_well(holder, env, family, tmp_path) + + +# --- 3. a clone of its own, added by hand, that no section names ----------- + +@pytest.mark.parametrize("committed", [True, False], + ids=["committed", "staged"]) +def test_issue_3_a_clone_no_section_names_comes_out_cached_and_is_kept( + family, holder, offline, tmp_path, committed): + """The issue's state 3: IRQS cloned by hand into `members/IRQS`, with a + commit only that clone holds, then `git add members/IRQS`, committed or + not, and no section giving the path. The printed `git rm -- + members/IRQS` died (`could not lookup name for submodule`, exit 128). + It is `git rm --cached` now, in every mode, `-f` where the index + stages it and HEAD records nothing, and the text says to move the + clone out whole, with the `git log` that lists its commit. Typed, the + clone moved out of the holder as it says, and committed where HEAD + recorded it: nothing is left, the moved clone keeps its commit, the + gate passes in every mode and a plain clone checks both members out.""" + env = cloning(offline, tmp_path, family) + raw(holder, env, "clone", "-q", "--", + str(family["remotes"] / f"{SPARE}.git"), HAND) + clone = holder / HAND + raw(clone, env, "-c", "user.name=t", "-c", "user.email=t@t", "commit", + "-q", "--allow-empty", "-m", "Only this clone holds it") + made = raw(clone, env, "rev-parse", "HEAD").stdout.decode("ascii").strip() + raw(holder, env, "add", "--", HAND) + if committed: + commit_all(holder, env, "A clone mounted by hand") + + found = found_alike(holder, *MODES) + + mount = mount_of(found, HAND) + flag = "" if committed else " -f" + assert commands(mount)[1] == f"git rm --cached{flag} -- {HAND}" + assert (CACHED_SAID.format(forced=FORCED if flag else "") + CLONE_WHY + + (COMMIT_THAT if committed else NOTHING_LEFT)) in mount, mount + assert CLONE_LEFT.format(path=HAND) in mount, mount + listed = raw(clone, env, "log", "--oneline", "HEAD", "--branches", + "--not", "--remotes").stdout.decode().split() + assert made.startswith(listed[0]), listed + kept = tmp_path / "kept-out" + typed_around(holder, env, found, + lambda: shutil.move(str(clone), str(kept))) + committed_if_staged(holder, env) + assert raw(holder, env, "status", "--porcelain").stdout == b"" + assert raw(kept, env, "cat-file", "-e", made, + check=False).returncode == 0 + ends_well(holder, env, family, tmp_path) + + +# --- beyond the issue: what is on disk is no checkout ---------------------- + +NO_CHECKOUTS = {"a-directory-holding-files": files_at, "a-file": a_file_at} + + +@pytest.mark.parametrize("make", NO_CHECKOUTS.values(), ids=NO_CHECKOUTS) +def test_beyond_what_is_no_checkout_comes_out_cached( + family, holder, offline, tmp_path, make): + """Beyond the issue, its goal: a hand mount committed with nothing on + disk, then a directory holding a file and no `.git`, or a file, made at + its path. The printed `git rm -- members/IRQS` exited 1 (`the following + file has local modifications`). It is `git rm --cached` now, in every + mode, and the text says what is left. Typed, what is left deleted by + hand, and committed: nothing is left, the gate passes in every mode + and a plain clone checks both members out.""" + env = cloning(offline, tmp_path, family) + path = hand_mounted(holder, env, family) + make(holder, path) + + found = found_alike(holder, *MODES) + + mount = mount_of(found, path) + assert commands(mount)[1] == f"git rm --cached -- {path}" + assert (CACHED_SAID.format(forced="") + NO_CHECKOUT_WHY + + COMMIT_THAT) in mount, mount + assert NO_CHECKOUT_LEFT.format(path=path) in mount, mount + typed_around(holder, env, found, lambda: deleted(holder / path)) + committed_if_staged(holder, env) + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +# --- the controls: the plain git rm, which works --------------------------- + +def clone_a_section_names(root: Path, env: dict, family) -> str: + """IRQS cloned by hand into `members/IRQS`, its `.git` a directory, then + `git submodule add` of it, which writes its section, committed. Returns + the path.""" + remote = str(family["remotes"] / f"{SPARE}.git") + raw(root, env, "clone", "-q", "--", remote, HAND) + raw(root, env, "submodule", "add", "-q", "--", remote, HAND) + commit_all(root, env, "A clone a section names") + return HAND + + +CONTROLS = { + "nothing-on-disk": hand_mounted, + "a-checkout-and-its-section": committed_with_its_checkout, + "a-clone-a-section-names": clone_a_section_names, +} + + +@pytest.mark.parametrize("make", CONTROLS.values(), ids=CONTROLS) +def test_control_a_hand_mount_the_plain_git_rm_takes_keeps_it( + family, holder, offline, tmp_path, make): + """The controls: a hand mount with nothing on disk, one with its + checkout and its section, and a clone whose path a section gives, + which a plain `git rm` moves into the holder's store first. Each prints + the plain `git rm`, as before this change. Typed in order and + committed, nothing is left, the gate passes in every mode and a plain + clone checks both members out.""" + env = cloning(offline, tmp_path, family) + path = make(holder, env, family) + + found = found_alike(holder, *MODES) + + mount = mount_of(found, path) + assert commands(mount)[1] == f"git rm -- {path}" + assert PLAIN_SAID in mount, mount + typed_in_order(holder, env, validate(holder, "--no-members")) + commit_all(holder, env, "Typed in order") + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) diff --git a/tests/test_family_holder_remedies_compose.py b/tests/test_family_holder_remedies_compose.py index 5cf8899b..b6e5effa 100644 --- a/tests/test_family_holder_remedies_compose.py +++ b/tests/test_family_holder_remedies_compose.py @@ -68,10 +68,12 @@ LANE 1'S REVIEW OF #889, ROUND 2, TAKEN HERE: a hand mount's `git rm` writes nothing into a `.gitmodules` that is gone, so a removal after it puts the file back itself rather than write a section into no file (MUST -2); a clone at no case variant of the row's path is not said to be one -`family.py add` refuses (the NIT, lane 1's mutant MY3); and a hand mount's -`git rm` says it refuses a gitlink staged at a commit HEAD does not record -there (the SHOULD). +2); and a clone at no case variant of the row's path is not said to be one +`family.py add` refuses (the NIT, lane 1's mutant MY3). The SHOULD, a hand +mount's `git rm` over a gitlink staged at a commit HEAD does not record +there, is #936's, whose rows are in +`tests/test_family_hand_mount_git_rm_lines.py`, with the helpers at the end +of this file for its states. THE SAME FAMILY, OFFLINE RULES AND HELPERS as `tests/test_family_holder_bootstrap_texts.py` (#839) and @@ -1406,21 +1408,6 @@ def test_round2_nit_a_clone_at_no_case_variant_is_not_said_refused( ends_well(holder, env, family, tmp_path) -#: What a hand mount's text says of its `git rm` where git refuses the -#: gitlink as staged (lane 1's SHOULD on #889, round 2), with git's two -#: refusals (its NIT 1, round 3), and elsewhere. -STAGED_REFUSED = ( - "take it out with the second, which refuses while its checkout holds " - "changes, and while the index stages it at a commit HEAD does not record " - "there, as it does here (the following file has changes staged in the " - "index, or, where what is on disk differs too, has staged content " - "different from both the file and the HEAD, exit 1), and takes its " - ".gitmodules section out too") -PLAIN_REFUSED = ("take it out with the second, which refuses while its " - "checkout holds changes and takes its .gitmodules section " - "out too") - - def moved_by_git_mv(root: Path, env: dict, family) -> str: """`git mv members/IRRS members/IRRS2`, staged. Returns the new path.""" raw(root, env, "mv", "--", ROW, f"{ROW}2") @@ -1467,59 +1454,3 @@ def moved_back_and_added(root: Path, env: dict, family) -> str: raw(root / path, env, "checkout", "-q", "HEAD~1") raw(root, env, "add", "--", path) return path - - -#: `(the state, whether a plain git rm refuses its gitlink as staged)`: -#: measured on git 2.43, it took one staged over nothing on disk, and one -#: committed with its checkout. Where it refuses, the first three say `has -#: changes staged in the index`, and the others, whose disk differs from -#: what is staged, `has staged content different from both the file and -#: the HEAD` (lane 1's NIT 1 on #889, round 3). -STAGED_STATES = { - "git-mv": (moved_by_git_mv, True), - "an-empty-directory": (staged_by_hand(Path.mkdir), True), - "head-records-another-commit": (moved_back_and_added, True), - "git-mv-and-a-commit-in-it": (moved_with_a_commit, True), - "a-directory-holding-a-file": (staged_by_hand(holding_a_file), True), - "nothing-on-disk": (staged_by_hand(lambda where: None), False), - "committed-with-its-checkout": (committed_with_its_checkout, False), -} - - -def git_refusal(stderr: bytes) -> str: - """What `git rm` said it refused, after "the following file ", up to - its colon, on one line: git wraps the longer of its two refusals.""" - said = stderr.decode("utf-8").split("the following file ", 1)[1] - return " ".join(said.split(":", 1)[0].split()) - - -@pytest.mark.parametrize("make, refused", STAGED_STATES.values(), - ids=STAGED_STATES) -def test_round2_should_a_hand_mount_s_git_rm_says_it_refuses_a_staged_gitlink( - family, holder, offline, tmp_path, make, refused): - """Lane 1's SHOULD on #889, round 2: a gitlink no row names, staged at - a commit HEAD does not record there, as `git mv members/IRRS - members/IRRS2` leaves one, or as one staged by hand over an empty - directory. The hand mount's `git rm -- ` exits 1 there, as - before this change, where its text said only that it refuses while - its checkout holds changes. It says that refusal too now, in every - mode, and not where nothing is on disk or HEAD records the gitlink as - staged, which git takes. No line takes such a gitlink out: `-f` would - take the checkout's changes with it. Round 3 (lane 1's NITs 1 and 2): - the text quotes the refusal git prints, either of two, and a gitlink - HEAD records at another commit gets it too.""" - env = cloning(offline, tmp_path, family) - path = make(holder, env, family) - - found = found_alike(holder, *MODES) - - mount = found[0] - assert mount.startswith(f"FINDING member-gitlink-unnamed: {path} "), mount - assert (STAGED_REFUSED if refused else PLAIN_REFUSED) in mount, mount - switch, removal, _ = commands(mount) - assert removal == f"git rm -- {path}" - type_line(holder, env, switch) - typed = raw(holder, env, *shlex.split(removal)[1:], check=False) - assert typed.returncode == int(refused), typed.stderr - if refused: - assert git_refusal(typed.stderr) in mount, typed.stderr From e97aa0ea37e29671cb531a933cf6b4d520236972 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 20:01:04 +0000 Subject: [PATCH 2/7] tests/test_family_hand_mount_git_rm_lines.py: a gitlink staged over a dangling link is taken out cached, so `_staged_over` reading `exists` in place of `lexists` fails a row (#936) x889c's NIT A on #889 (issuecomment-6253217474): nothing tested `_staged_over`'s `os.path.lexists` against a dangling link. Under `(root / path).exists()` the reason was lost while git still refuses the plain `git rm` (exit 1, "has staged content different from both the file and the HEAD"). The row expects `git rm --cached -f` with the staged reason; the mutant is killed (1 failed, 6 passed). Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- tests/test_family_hand_mount_git_rm_lines.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_family_hand_mount_git_rm_lines.py b/tests/test_family_hand_mount_git_rm_lines.py index 87da3382..58b418ba 100644 --- a/tests/test_family_hand_mount_git_rm_lines.py +++ b/tests/test_family_hand_mount_git_rm_lines.py @@ -9,7 +9,8 @@ 1. A gitlink the index stages at a commit HEAD does not record there, with something on disk at it: `git mv members/IRRS members/IRRS2`, staged, clean or with a commit in the checkout it moved; a gitlink staged over - an empty directory or a directory holding a file; a `git submodule add` + an empty directory, a directory holding a file or a dangling link (x889c's + NIT A on #889); a `git submodule add` not committed; a checkout moved to `HEAD~1` and `git add`-ed. The printed `git rm -- ` exited 1 in each (`the following file has changes staged in the index`, or, where what is on disk differs from @@ -185,8 +186,10 @@ def added_not_committed(root: Path, env: dict, family) -> str: #: `(the state, the line's flag, whether HEAD records the gitlink, what is #: left on disk)`. Typed, a plain `git rm` said `has changes staged in the #: index` in the first four, and `has staged content different from both -#: the file and the HEAD` in the last two, whose disk differs from what is -#: staged (lane 1's NIT 1 on #889, round 3). +#: the file and the HEAD` in the last three, whose disk differs from what +#: is staged (lane 1's NIT 1 on #889, round 3). A dangling link is +#: something on disk, as `os.path.lexists` reads it and git refuses it, +#: where `exists` reads nothing (x889c's NIT A on #889). STAGED = { "git-mv": (moved_by_git_mv, " -f", False, CHECKOUT_LEFT), "an-empty-directory": (staged_by_hand(Path.mkdir), "", False, @@ -199,6 +202,9 @@ def added_not_committed(root: Path, env: dict, family) -> str: CHECKOUT_LEFT), "a-directory-holding-a-file": (staged_by_hand(holding_a_file), " -f", False, NO_CHECKOUT_LEFT), + "a-dangling-link": (staged_by_hand( + lambda where: where.symlink_to("nowhere")), " -f", False, + NO_CHECKOUT_LEFT), } From 02bd9e10aa37772c40376a71ba5b44e1b5113a5f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 19 Oct 2026 15:51:51 +0000 Subject: [PATCH 3/7] validate-family.py: a hand mount's switch line is written in its remedy's own text, so the guard that every remedy naming `_switch_line` ends its prose with `BRANCH_LEFT` sees the sentence (#936) The 37-file run on 40e6e27b failed two tests in tests/test_family_switch_branch_exists.py: the guard, and the test that shows the guard is not vacuous. `_mounted_by_hand` built its lines as a list that began with `_switch_line(...)`. The guard reads the string expression a `_switch_line` call stands in, and for this call that was the list. The printed text was the same, and its prose ended with `BRANCH_LEFT`, but the guard could not see it. - `lines` now holds the lines after the switch line. The returned text writes the switch line itself, as main's did. - The helpers number their lines from the second (`_ordinal(len(lines))`), and the add is the `_ordinal(len(lines) + 1)`. - The printed text is unchanged. The 18 rows of tests/test_family_hand_mount_git_rm_lines.py and the two guard tests pass (20 passed). Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- .../family-root/scripts/validate-family.py | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index 25475c88..f7f4e58c 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -2958,7 +2958,11 @@ def _mounted_by_hand(root: Path, path: str, project: str | None, f".git/modules if one is still there. {BRANCH_LEFT}\n" f" {_switch_line(root, 'add', project)}\n{add}") why = _hand_cached(root, path, commit) - lines = [_switch_line(root, "add", project)] + # The lines after the switch line, which is the first and is written + # in the text itself, so that the prose it stands under is seen to end + # with `BRANCH_LEFT` (`_switch_line`): the one at `lines[i]` is the + # `_ordinal(i + 1)`. + lines: list[str] = [] said = (_plain_rm_said(root, path, stage_first, report, lines) if why is None else _cached_rm_said(root, path, commit, why, stage_first, lines)) @@ -2966,11 +2970,12 @@ def _mounted_by_hand(root: Path, path: str, project: str | None, f"{RESTORE_ROW} If it was mounted by hand, make a branch " "with the first command below, since family.py add refuses the " f"holder's tracking branch, {said}, then mount it with the " - f"{_ordinal(len(lines))}, from a checkout of openRepoShape: " + f"{_ordinal(len(lines) + 1)}, from a checkout of openRepoShape: " "family.py add writes its row and its gitlink in one commit, and " "names the object store git rm leaves under .git/modules if one is " f"still there.{_cached_left(root, path) if why else ''} " f"{BRANCH_LEFT}\n" + f" {_switch_line(root, 'add', project)}\n" + "".join(f" {line}\n" for line in lines) + add) @@ -2981,20 +2986,22 @@ def _mounted_by_hand(root: Path, path: str, project: str | None, def _staging_said(stage_first: bool, lines: list[str]) -> str: """What the text says of `git add -- .gitmodules`, appended to `lines`, - where `stage_first` (#879's C3), else nothing.""" + the lines after the switch line (`_mounted_by_hand`), where + `stage_first` (#879's C3), else nothing.""" if not stage_first: return "" lines.append(STAGE_LINE) - return (f"stage {GITMODULES} with the {_ordinal(len(lines) - 1)}, " + return (f"stage {GITMODULES} with the {_ordinal(len(lines))}, " f"{STAGE_TOO}, {STAGE_WHY}, ") def _plain_rm_said(root: Path, path: str, stage_first: bool, report: Report | None, lines: list[str]) -> str: """What the text says of a hand mount's plain `git rm`, from "take it - out" to "commit that removal", its lines appended to `lines`: the work - tree's `.gitmodules` put back first where it is gone and the file git - reads in its place gives `path` (`_file_put_back`, #936).""" + out" to "commit that removal", its lines appended to `lines`, the lines + after the switch line (`_mounted_by_hand`): the work tree's + `.gitmodules` put back first where it is gone and the file git reads + in its place gives `path` (`_file_put_back`, #936).""" put = "" if (not os.path.lexists(root / GITMODULES) and path in _given_as_git_reads(root)): @@ -3002,7 +3009,7 @@ def _plain_rm_said(root: Path, path: str, stage_first: bool, if line: lines.append(line) put = (f"put {GITMODULES}, gone from the work tree, back from " - f"{whose} with the {_ordinal(len(lines) - 1)}, so that " + f"{whose} with the {_ordinal(len(lines))}, so that " "the git rm takes its section out of that file and every " "other section stays, ") staging = _staging_said(stage_first, lines) @@ -3010,7 +3017,7 @@ def _plain_rm_said(root: Path, path: str, stage_first: bool, if report is not None: report.wrote = True return (f"{put}{staging}take it out with the " - f"{_ordinal(len(lines) - 1)}, which refuses while its checkout " + f"{_ordinal(len(lines))}, which refuses while its checkout " f"holds changes and takes its {GITMODULES} section out too, and " f"commit that removal, {COMMIT_WHY}") @@ -3018,10 +3025,11 @@ def _plain_rm_said(root: Path, path: str, stage_first: bool, def _cached_rm_said(root: Path, path: str, commit: str, why: str, stage_first: bool, lines: list[str]) -> str: """What the text says of a hand mount's `git rm --cached` (#936), - from "take it out" to the commit, its lines appended to `lines`: why - a plain `git rm` is not printed (`CACHED_WHY`), `-f` where `_forced` - says, with what it does (`FORCED_SAID`), and no commit of it where - HEAD records nothing at `path`.""" + from "take it out" to the commit, its lines appended to `lines`, the + lines after the switch line (`_mounted_by_hand`): why a plain `git rm` + is not printed (`CACHED_WHY`), `-f` where `_forced` says, with what it + does (`FORCED_SAID`), and no commit of it where HEAD records nothing + at `path`.""" staging = _staging_said(stage_first, lines) head = _forced(root, path, commit) lines.append(_cached_removal(path, head)) @@ -3031,7 +3039,7 @@ def _cached_rm_said(root: Path, path: str, commit: str, why: str, "; HEAD records nothing there, so nothing of it is left " "to commit") return (f"{staging}take it out of the index alone with the " - f"{_ordinal(len(lines) - 1)}, which touches no file{forced}, " + f"{_ordinal(len(lines))}, which touches no file{forced}, " f"since a plain git rm refuses it here: {CACHED_WHY[why]}" f"{commit_said}") From 927fabf73ca52e58e0523700ad34e8140a0f43c8 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 19 Oct 2026 21:41:11 +0000 Subject: [PATCH 4/7] validate-family.py: what is on disk at a hand mount is asked as #956 asks it, so a path or a `.git` this user cannot read is `member-unreadable` by name, never a 3.12 traceback or a false "no checkout", and a hand mount HEAD records is refused under a folder where its plain `git rm` exits 128 (#936) Moved onto main 3864b18c, where #956 (slice E of #430) landed. The rebase had no textual conflict, because #956's hunks and this branch's are apart. Its rule met #936's new reads, though: tests/test_validate_family_unreadable.py failed 3 of 52 on 02bd9e10, the third commit moved: - hand-mount-folder-000 and hand-mount-committed, members/ at mode 000: `_hand_cached`'s `git.is_dir()` ended the gate in a PermissionError traceback on Python 3.12. - hand-mount-committed, a refused `stat` of members/IRQS in process: `_on_disk` refused it `member-unreadable`, exit 2, where the row expected the control's finding, exit 1. Resolved with #936's intent, every hand mount's line works typed, and #430's, a path this user cannot read is never absent: - `_hand_cached` asks the path first (`_anything_at`). Nothing there is the plain line, and a folder above it that cannot be searched is refused at the path, as `_staged_over` refuses it. "clone" asks the file git reads before the `.git`, and the `.git` with `_asked`; "no checkout" asks the `.git` with `_anything_at`. `_cached_left` asks its `.git` the same way. - A hand mount HEAD records is asked of the disk too, since whether its plain `git rm` works turns on what is there. With members/ at mode 000, git 2.43's `git rm -- members/IRQS`, the line main prints there, exits 128: "warning: failed to stat 'members/IRQS': Permission denied", "fatal: could not remove 'members/IRQS'". So r956's C19 row for it moves. `test_a_gitlink_head_records_as_staged_asks_nothing_of_the_disk` and `test_a_folder_under_a_gitlink_git_takes_is_never_asked` keep their variant row (C20). The hand mount is refused by name in `test_a_path_whose_stat_is_refused_is_refused_by_name` (hand-mount-committed) and in `test_a_folder_this_user_cannot_search_is_refused_by_name` (hand-mount-committed-folder-000, and hand-mount-committed-444 for its `.git`). - tests/test_family_hand_mount_git_rm_lines.py: two rows, a checkout and its section committed, and `git submodule add` not committed, each at mode 444. Every mode is refused by name at `/.git`, and with the mode back the gate says what it said. tests/test_validate_family_unreadable.py: 53 passed. The hand mount file and tests/test_family_switch_branch_exists.py: 45 passed. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- .../family-root/scripts/validate-family.py | 29 +++++++++--- tests/test_family_hand_mount_git_rm_lines.py | 45 +++++++++++++++++++ tests/test_validate_family_unreadable.py | 41 ++++++++++++----- 3 files changed, 98 insertions(+), 17 deletions(-) diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index f7f4e58c..c8d26c01 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -2857,13 +2857,27 @@ def _hand_cached(root: Path, path: str, commit: str) -> str | None: `_forced` says, and what it leaves is said with what to do with it (`_cached_left`); the `.gitmodules` section it leaves, where one gives the path, `gitmodules-path-unnamed` takes out by key - (`_check_gitmodules_named`).""" + (`_check_gitmodules_named`). + + WHAT IS ON DISK IS ASKED AS #430 ASKS IT (`_anything_at`, `_asked`): a + `stat` the operating system refuses is `member-unreadable` by name, + never nothing there and never a traceback. The path itself is asked + first, so a folder this user cannot search above it is refused at the + path, as `_staged_over` refuses it, and nothing there is the plain + line. Where HEAD records the gitlink at the index's own commit too, + the disk is asked: whether a plain `git rm` takes it turns on what is + there, a clone no section names or what is no checkout, and under a + folder this user cannot search it exits 128 (`failed to stat`, `could + not remove`), so the line it printed there was no line that works.""" + if not _anything_at(root / path, path): + return None git = root / path / ".git" - if git.is_dir() and path not in _given_as_git_reads(root): + if (path not in _given_as_git_reads(root) + and _asked(git, stat.S_ISDIR, MEMBER_UNREADABLE, f"{path}/.git")): return "clone" if _staged_over(root, path, commit): return "staged" - if _on_disk(root, path) and not os.path.lexists(git): + if _on_disk(root, path) and not _anything_at(git, f"{path}/.git"): return "no checkout" return None @@ -2882,16 +2896,19 @@ def _cached_left(root: Path, path: str) -> str: whole, as `member-gitlink-removal-staged` says of one (`_moved_said`); a checkout whose `.git` file names a store keeps its commits there, so only what is not committed is lost with it; anything else is moved or - deleted, and an empty directory deleted.""" + deleted, and an empty directory deleted. Its `.git` is asked as #430 + asks it (`_asked`, `_anything_at`): one whose `stat` is refused is + `member-unreadable` by name, never no checkout.""" git = root / path / ".git" + named = f"{path}/.git" then = ADD_REFUSES.format(path=path) - if git.is_dir(): + if _asked(git, stat.S_ISDIR, MEMBER_UNREADABLE, named): return (f" The clone at {path}, whose .git is a directory, stays " "there, untracked: it holds its commits there and nowhere " f"else, and {_unpushed([path])} lists the ones not pushed, " "so move it out of the holder whole, by hand, rather than " f"delete it, {then}") - if os.path.lexists(git): + if _anything_at(git, named): return (f" The checkout at {path} stays there, untracked: its .git " "file names the store that keeps its commits whatever " "becomes of the checkout, so only what is not committed is " diff --git a/tests/test_family_hand_mount_git_rm_lines.py b/tests/test_family_hand_mount_git_rm_lines.py index 58b418ba..2a7b0194 100644 --- a/tests/test_family_hand_mount_git_rm_lines.py +++ b/tests/test_family_hand_mount_git_rm_lines.py @@ -41,6 +41,12 @@ its section, and a clone a section names, which a plain `git rm` moves into the holder's store first: each still prints the plain `git rm`, which works. +WHAT CANNOT BE READ (#430, met on main after #956): what is on disk at a +hand mount is asked as #956 asks it, so a `.git` whose `stat` is refused is +`member-unreadable` by name, never a traceback or a false "no checkout". +`tests/test_validate_family_unreadable.py` holds the rows for the path +itself and the folder above it. + Each row types every `git` line as printed, in the order printed, split as a POSIX shell splits it and run with no shell (not `family.py add`, which mounts a hand mount again only where the person wants it), deals by hand @@ -58,6 +64,7 @@ import pytest from conftest import rmtree +from test_bump_leg_unopenable import made_unopenable, needs_a_mode from test_family import SPARE, family, validate # noqa: F401 from test_family_add_refusals import holder, offline # noqa: F401 from test_family_bootstrap_gate_order import hand_mounted, type_line @@ -429,3 +436,41 @@ def test_control_a_hand_mount_the_plain_git_rm_takes_keeps_it( commit_all(holder, env, "Typed in order") assert raw(holder, env, "status", "--porcelain").stdout == b"" ends_well(holder, env, family, tmp_path) + + +# --- what cannot be read (#430) -------------------------------------------- + +#: A hand mount's checkout with its section, committed, whose plain `git rm` +#: turns on its `.git` (`_hand_cached`), and the same staged by `git +#: submodule add`, whose `--cached` line says what it leaves by its `.git` +#: (`_cached_left`). +UNSEARCHABLE = { + "a-checkout-and-its-section": committed_with_its_checkout, + "submodule-add-not-committed": added_not_committed, +} + + +@needs_a_mode +@pytest.mark.parametrize("make", UNSEARCHABLE.values(), ids=UNSEARCHABLE) +def test_a_hand_mount_s_git_this_user_cannot_reach_is_refused_by_name( + family, holder, offline, tmp_path, make): + """WHAT IS ON DISK IS ASKED AS #430 ASKS IT (#956): the checkout at mode + 444, which this user can list but not search, so the `stat` of its + `.git` is refused. `os.path.lexists` read no `.git` there, a false "no + checkout" and a `git rm --cached` the plain line did not need, and + `Path.is_dir()` ended the run in a traceback on 3.12. Now each mode is + refused by name, exit 2, with the `chmod` cure, and with the mode back + the gate says what it said before.""" + env = cloning(offline, tmp_path, family) + path = make(holder, env, family) + before = found_alike(holder, *MODES) + with made_unopenable(holder, path, mode=0o444): + refused = [validate(holder, *extra) for extra in MODES] + for result in refused: + assert result.returncode == 2, result.stderr + assert result.stderr.splitlines()[0] == ( + f"REFUSED member-unreadable: {path}/.git: cannot be read " + "(Permission denied)"), result.stderr + assert "Traceback" not in result.stderr + assert "chmod u+rx" in result.stderr + assert found_alike(holder, *MODES) == before diff --git a/tests/test_validate_family_unreadable.py b/tests/test_validate_family_unreadable.py index 9f5ec8e1..de452614 100644 --- a/tests/test_validate_family_unreadable.py +++ b/tests/test_validate_family_unreadable.py @@ -31,14 +31,19 @@ `family.yaml`, the staged removal's `_put_back_lines` and `_moved_checkouts` (`_gone`: nothing there is ENOENT alone, so a file where the checkout should be is refused, never "none there"), `_names_store`, `_on_disk` and -`_held_as`, which decide the `-f` of a gitlink's `git rm --cached`, and +`_held_as`, which decide the `-f` of a gitlink's `git rm --cached`, `_staged_over` (#889's round 2), which decides whether a hand mount's `git -rm` is said to refuse a staged gitlink. +rm` is said to refuse a staged gitlink, and `_hand_cached` and +`_cached_left` (#936), which decide whether it is `git rm --cached`. WHAT IS ON DISK IS ASKED ONLY WHERE IT IS SAID (round 2, r956's SHOULD 1 on #956): `_forced` and `_staged_over` ask git first, and where HEAD records a gitlink at the index's own commit, the disk under it is never -asked, so a folder this user cannot search there changes nothing. And ONE +asked, so a folder this user cannot search there changes nothing. A hand +mount's is said (#936): whether its plain `git rm` works turns on what is +there, a clone no section names or what is no checkout, and under a folder +this user cannot search that `git rm` exits 128, so a hand mount HEAD +records is refused by name there too (`_hand_cached`). And ONE CODE HAS ONE TEXT (r956's SHOULD 2): a member's `project.yaml` that cannot be opened says what one whose `stat` is refused says. @@ -187,8 +192,9 @@ def hand_mount_staged(holder: Path, request) -> tuple[str, ...]: def hand_mount_committed(holder: Path, request) -> tuple[str, ...]: """The hand mount committed: HEAD records SPARE's gitlink at the - index's own commit, so a plain `git rm` takes it, no `-f` is said and - no staged clause, whatever is on disk (r956's C19 on #956).""" + index's own commit, so no `-f` is said and no staged clause, whatever + is on disk (r956's C19 on #956). Whether a plain `git rm` takes it + turns on what is on disk (#936): over this empty directory it does.""" extra = hand_mount_staged(holder, request) git("commit", "-qm", "A hand mount", cwd=holder) return extra @@ -281,6 +287,8 @@ def test_what_is_not_there_is_said_as_it_always_was(holder, request, made, f"{VARIANT_PATH}/.git", id="variant-checkout"), pytest.param(hand_mount_staged, (SPARE,), MEMBER_UNREADABLE, HAND, id="hand-mount-staged"), + pytest.param(hand_mount_committed, (SPARE,), MEMBER_UNREADABLE, HAND, + id="hand-mount-committed"), ]) def test_a_path_whose_stat_is_refused_is_refused_by_name( holder, request, monkeypatch, capsys, made, names, refusal, named): @@ -508,7 +516,6 @@ def said(module, holder: Path, capsys, extra: tuple[str, ...]) -> tuple: @pytest.mark.parametrize("made,names", [ - pytest.param(hand_mount_committed, (SPARE,), id="hand-mount-committed"), pytest.param(variant_committed, ("IRRS",), id="variant-committed"), ]) def test_a_gitlink_head_records_as_staged_asks_nothing_of_the_disk( @@ -518,7 +525,10 @@ def test_a_gitlink_head_records_as_staged_asks_nothing_of_the_disk( the `-f` (`_forced`) nor the staged clause (`_staged_over`) is said, whatever is on disk, and a path whose `stat` would be refused is never asked. The finding is the control's, word for word, on every Python: - asked first, the disk refused it `member-unreadable`, exit 2.""" + asked first, the disk refused it `member-unreadable`, exit 2. A hand + mount HEAD records is no row here: whether its plain `git rm` works + turns on what is on disk (#936), so it is refused by name + (`test_a_path_whose_stat_is_refused_is_refused_by_name`).""" extra = made(holder, request) module = gate(holder, monkeypatch) control = said(module, holder, capsys, extra) @@ -603,6 +613,12 @@ def test_a_name_that_needs_spelling_is_spelled_where_it_cannot_be_read( "member-gitlink-removal-staged"], id="variant-000"), pytest.param(hand_mount_staged, "members", 0, MEMBER_UNREADABLE, HAND, ["member-gitlink-unnamed"], id="hand-mount-folder-000"), + pytest.param(hand_mount_committed, "members", 0, MEMBER_UNREADABLE, HAND, + ["member-gitlink-unnamed"], + id="hand-mount-committed-folder-000"), + pytest.param(hand_mount_committed, HAND, 0o444, MEMBER_UNREADABLE, + f"{HAND}/.git", ["member-gitlink-unnamed"], + id="hand-mount-committed-444"), ]) def test_a_folder_this_user_cannot_search_is_refused_by_name( holder, request, made, folder, mode, refusal, named, after): @@ -622,14 +638,17 @@ def test_a_folder_this_user_cannot_search_is_refused_by_name( @needs_a_mode @pytest.mark.parametrize("made,folder", [ - pytest.param(hand_mount_committed, "members", id="hand-mount-committed"), pytest.param(variant_committed, "Members", id="variant-committed"), ]) def test_a_folder_under_a_gitlink_git_takes_is_never_asked( holder, request, made, folder): - """r956's C19 and C20 on #956, on this Python: with the folder at mode - 000, the gate says what it says with the folder open, word for word, - since nothing it says turns on what is in that folder.""" + """r956's C20 on #956, on this Python: with the folder at mode 000, the + gate says what it says with the folder open, word for word, since + nothing it says turns on what is in that folder. C19's hand mount is no + row here: what its line says turns on that folder (#936), and a plain + `git rm` of it under a folder at mode 000 exits 128 (`failed to stat`, + `could not remove`), so it is refused by name + (`hand-mount-committed-folder-000` above).""" extra = made(holder, request) control = validate(holder, *extra) with made_unopenable(holder, folder): From 4c0c52d0c7ebb574431f53394e20731b46bb1f1d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 05:10:02 +0000 Subject: [PATCH 5/7] tests/test_family_hand_mount_git_rm_lines.py: a hand edit of `.gitmodules` not staged puts `git add -- .gitmodules` before a hand mount's `git rm --cached`, so a stage line printed after it, where git's rm exits 128, fails a row (#936) RV1025's NIT 2 on #1025: no row reached `_staging_said` in `_cached_rm_said`, so its mutant MG, which prints the stage line after the `--cached` line, passed every row of the new file and the compose file. The state is RV1025's probe: `git mv members/IRRS members/IRRS2`, staged, then a line appended to the work tree's `.gitmodules` by hand and not staged. The row pins the order (`git add -- .gitmodules`, then `git rm --cached -f -- members/IRRS2`), the text that numbers both, and what is left, then types every line in order, deletes the moved checkout as the text says, commits, and asks the gate in every mode and a plain clone. In MG's order the `git rm --cached -f` exits 128 (`please stage your changes to .gitmodules or stash them to proceed`). Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- tests/test_family_hand_mount_git_rm_lines.py | 59 +++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/test_family_hand_mount_git_rm_lines.py b/tests/test_family_hand_mount_git_rm_lines.py index 2a7b0194..191dd7d8 100644 --- a/tests/test_family_hand_mount_git_rm_lines.py +++ b/tests/test_family_hand_mount_git_rm_lines.py @@ -19,7 +19,10 @@ take the checkout's changes with it. The line is `git rm --cached` now, with `-f` over anything but an empty directory, which touches no file, and the text says why, quoting both refusals, what it leaves on disk - and what to do with it. + and what to do with it. Where the work tree's `.gitmodules` holds a + change not staged, `git add -- .gitmodules` comes first, since git + takes no gitlink out of the index until it is staged (RV1025's NIT 2 on + #1025). 2. A hand mount with its own section, then a staged removal of a member, with the work tree's `.gitmodules` deleted: the `git rm` took the section out of no file, the removal's put-back brought it back, and the @@ -246,6 +249,60 @@ def test_issue_1_a_gitlink_staged_over_what_is_on_disk_comes_out_cached( ends_well(holder, env, family, tmp_path) +#: What the text says where the work tree's `.gitmodules` holds a change the +#: index does not (`_stage_first`, #879's C3): `git add -- .gitmodules` +#: first, since git takes no gitlink out of the index, `--cached` and `-f` or +#: not, until it is staged (RV1025's NIT 2 on #1025). +STAGED_FIRST = ( + "stage .gitmodules with the second, which stages every other change in " + "it too, since git rm refuses to take any gitlink out of the index, -f " + "or not, while .gitmodules holds changes not staged (please stage your " + "changes to .gitmodules or stash them to proceed, exit 128), as lines " + "printed above may leave it or as it stands, take it out of the index " + "alone with the third, which touches no file{forced}, since a plain git " + "rm refuses it here: ") +NOTHING_LEFT_FOURTH = ("; HEAD records nothing there, so nothing of it is " + "left to commit, then mount it with the fourth") + + +def edited_after_git_mv(root: Path, env: dict, family) -> str: + """`moved_by_git_mv`'s state, then a line appended to the work tree's + `.gitmodules` by hand and not staged. Returns the moved path.""" + path = moved_by_git_mv(root, env, family) + with (root / ".gitmodules").open("ab") as handle: + handle.write(b"# a note made by hand\n") + return path + + +def test_issue_1_a_gitmodules_change_not_staged_is_staged_before_cached( + family, holder, offline, tmp_path): + """State 1's `git mv members/IRRS members/IRRS2`, staged, then a line + appended to the work tree's `.gitmodules` by hand and not staged + (RV1025's NIT 2 on #1025). Git takes no gitlink out of the index, + `--cached` and `-f` or not, while that file holds changes the index + does not: `git rm --cached -f` typed first exits 128 (`please stage + your changes to .gitmodules or stash them to proceed`). So the + `--cached` line comes after `git add -- .gitmodules`, in every mode, + and the text numbers both. Typed in order, the moved checkout deleted + by hand as the text says, then committed: nothing is left, the gate + passes in every mode and a plain clone checks both members out.""" + env = cloning(offline, tmp_path, family) + path = edited_after_git_mv(holder, env, family) + + found = found_alike(holder, *MODES) + + mount = mount_of(found, path) + assert commands(mount)[1:3] == ["git add -- .gitmodules", + f"git rm --cached -f -- {path}"], mount + assert (STAGED_FIRST.format(forced=FORCED) + STAGED_WHY + + NOTHING_LEFT_FOURTH) in mount, mount + assert CHECKOUT_LEFT.format(path=path) in mount, mount + typed_around(holder, env, found, lambda: deleted(holder / path)) + committed_if_staged(holder, env) + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + # --- 2. a hand mount's section, with the work tree's file gone ------------ #: The flags of the removal of MEMBER, by how it was staged. From f1290fe41c5e1a3bf3e65169e457890da4c503e2 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 17:19:12 +0000 Subject: [PATCH 6/7] validate-family.py: `gitmodules-path-unnamed` reads the file git reads where the work tree's `.gitmodules` is gone, the index's then HEAD's, and its lines put that file back first, so a section `git submodule add` staged is no longer committed past a passing gate (#1036) RV1025's SHOULD 1 on #1025. In its state, `git submodule add` at members/IRQS, not committed, then the work tree's `.gitmodules` deleted (`MD .gitmodules`), the finding read the work tree's file alone and named nothing: every printed line exited 0, the gate passed in every mode, and the commit of what was staged recorded IRQS's section in HEAD's file. Measured on git 2.43.0, isolated config: with the work tree's file gone, `git submodule status members/IRQS` maps the path, which HEAD's file does not give, so git reads the index's; `git config -f .gitmodules --unset-all ...` on no file exits 5 and makes none; the index's file put back and both keys unset leave it byte-identical to HEAD's. - `_sections_as_git_reads`: the work tree's sections where the file is on disk, else the file git reads in its place (`_gitmodules_put_back`), through `_blob_sections`. `_given_as_git_reads` and `_blob_paths` derive from it, unchanged in what they answer, so #936's hand-mount put-back and this finding agree on one file. - `_check_members` hands it to `_check_gitmodules_named`, but where `.gitmodules` gives a value git dies on: that finding's lines put HEAD's file back and stage it, which drops a section the index's file alone gave. Every other reader of `.gitmodules` is as it was. - `_section_out` takes the put-back (`_file_put_back`, one for every finding) and prints it right before the keys' lines, saying so; the case-variant branch and `_gitlink_out_first` pass it. A gitlink outside `members/` keeps its `git rm --cached` first, which needs no file. Rows at the end of tests/test_family_hand_mount_git_rm_lines.py: the issue's state, the same at tools/x, a stray section, two sections served by one put-back, a hand mount whose plain `git rm` takes its section (no command), and the control where a value git dies on keeps what it read. Two rows of tests/test_family_holder_remedies_compose.py move with the fix: C1's `--no-members` run names the section giving `Members/IRRS` (no command), as it does with the file on disk, and rv882's SHOULD 2 row, which said the gate passed, now expects the `gitmodules-path-unnamed` the same state is with the file on disk, typed through to a passing gate. Co-Authored-By: Claude Opus 5.5 --- .../family-root/scripts/validate-family.py | 104 +++++-- tests/test_family_hand_mount_git_rm_lines.py | 268 +++++++++++++++++- tests/test_family_holder_remedies_compose.py | 47 ++- 3 files changed, 385 insertions(+), 34 deletions(-) diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index 31e4d193..360d17a4 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -89,7 +89,10 @@ which HEAD still records, says to commit it, and prints no `git rm`, which would fail (#681). One no section of HEAD's `.gitmodules` gives a url is said to be checked out nowhere by a recursive clone, and to - stop a plain clone's `git submodule update --init` (#762). + stop a plain clone's `git submodule update --init` (#762). Where the + work tree's `.gitmodules` is gone from disk, the sections are read + from the file git reads in its place, the index's, then HEAD's, and + the lines that take one out put that file back first (#1036). - that every gitlink OUTSIDE `members/`, which no row can name, is at a path a section of `.gitmodules` gives, the `gitlink-unnamed` finding (#878): a plain clone's `git submodule update --init` stops at one no @@ -2836,12 +2839,29 @@ def _given_as_git_reads(root: Path) -> set[str]: """Every path a section of `.gitmodules` gives, as git's submodule commands read the file: the work tree's where it is on disk, else the one git reads in its place (`_gitmodules_put_back`, the index's, then - HEAD's), else none (rv882's SHOULD 2 on #882).""" + HEAD's), else none (rv882's SHOULD 2 on #882), read as + `_sections_as_git_reads` reads it, so the hand mount's put-back and + `gitmodules-path-unnamed` agree on one file (#1036).""" + return {keys["path"] for keys in _sections_as_git_reads( + root, _gitmodules_sections(root)).values() if "path" in keys} + + +def _sections_as_git_reads(root: Path, worktree: dict[str, dict[str, str]] + ) -> dict[str, dict[str, str]]: + """Every `submodule` section of `.gitmodules` as git's submodule + commands read the file, `{name: {key: value}}`: `worktree`, the work + tree's (`_gitmodules_sections`), where it is on disk, else the one git + reads in its place (`_gitmodules_put_back`, the index's, then HEAD's, + `_blob_sections`), else none (#1036). What `gitmodules-path-unnamed` + reads: from the work tree's alone, a section `git submodule add` + staged in the index's file, whose work tree's copy was deleted, was + never named, and the commit after the hand mount's lines recorded it. + Git 2.43 reads the index's there: `git submodule status ` maps + the path, which HEAD's file does not give.""" if os.path.lexists(root / GITMODULES): - return {keys["path"] for keys in _gitmodules_sections(root).values() - if "path" in keys} + return worktree spec = _gitmodules_put_back(root)[0] - return _blob_paths(root, spec) if spec else set() + return _blob_sections(root, spec) if spec else {} #: Why a plain `git rm` of a hand mount's gitlink refuses it where the @@ -3153,6 +3173,16 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], rest of each remedy is `_section_out`'s, after `_gitlink_out_first`'s `git rm` where it prints one, so this stays within S3776's limit (rv882's SHOULD 3 on #882). + + WHERE THE WORK TREE'S `.gitmodules` IS GONE FROM DISK, `sections` are + the file git reads in its place, the index's, then HEAD's + (`_sections_as_git_reads`, #1036), and each finding that prints lines + puts that file back before its keys' lines, unless a finding printed + before did (`_file_put_back`, recorded in `Report.put_back`): read from + the work tree alone, a section `git submodule add` staged, its file + then deleted, was never named, the hand mount's `git rm --cached` + exited 0, the gate passed, and the commit of what was staged recorded + the section (RV1025's SHOULD 1 on #1025). """ holds = {keys["path"] for keys in sections.values() if "path" in keys} given = Counter(keys["path"] for keys in sections.values() @@ -3173,7 +3203,8 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], "section")) continue said += _variant_said(*variant) + _section_out( - name, keys, "Take", f"then commit {GITMODULES}") + name, keys, "Take", f"then commit {GITMODULES}", + back=_file_put_back(root, report)) elif (path in gitlinks and path not in removed and given[path] == 1 and _in_members(path) and _hand_cached(root, path, gitlinks[path]) is None): @@ -3185,7 +3216,8 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], continue else: said += SECTION_ROW_BACK + _gitlink_out_first( - root, name, keys, gitlinks, removed, taken, edited) + root, name, keys, gitlinks, removed, taken, edited, + _file_put_back(root, report)) report.finding("gitmodules-path-unnamed", said) edited = report.wrote = True @@ -3207,14 +3239,28 @@ def _section_unnamed_said(name: str, keys: dict[str, str]) -> str: def _section_out(name: str, keys: dict[str, str], lead: str, then: str, - line: str = "") -> str: + line: str = "", back: tuple[str, str] = ("", "")) -> str: """The end of `gitmodules-path-unnamed`'s text, which takes the section `name` out by key (#300), every key it holds (`keys`), after `lead`, ending `then`, with `line`, a `git rm` printed first, before those - lines.""" + lines. + + WHERE THE WORK TREE'S `.gitmodules` IS GONE FROM DISK, `back` is the + line that puts it back from where git reads it and whose file that is + (`_file_put_back`), printed after `line` and before the keys', and + said: `git config -f` on no file exits 5 and makes none, measured on + git 2.43, so the keys' lines took nothing out (#1036). A `git rm + --cached` takes the gitlink out with no file on disk, so `line` stays + first.""" removal = "".join( f"\n git config -f {GITMODULES} --unset-all " f"{shlex.quote(f'submodule.{name}.{key}')}" for key in keys) + put_back, whose = back + if put_back: + then = (f"after the line that puts {GITMODULES}, gone from the work " + f"tree, back from {whose}, so that every other section " + f"stays, {then}") + removal = f"\n {put_back}{removal}" return (f" {lead} the section out by hand, in a POSIX shell, every key " "it holds, so a submodule later given its name inherits none of " f"them, {then}:{line}{removal}") @@ -3222,7 +3268,8 @@ def _section_out(name: str, keys: dict[str, str], lead: str, then: str, def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], gitlinks: dict[str, str], removed: frozenset[str], - taken: set[str], edited: bool) -> str: + taken: set[str], edited: bool, + back: tuple[str, str] = ("", "")) -> str: """The rest of `gitmodules-path-unnamed`'s text for the section `name`, no row naming its path in any case, after what it says of the row: where that path (`keys["path"]`) is a gitlink outside `members/` @@ -3255,12 +3302,17 @@ def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], whose removal is staged (`removed`), where a `git rm` fails (`pathspec ... did not match`), and for one an earlier section's finding took out (`taken`): the `git rm` typed again fails there too. A path it prints - one for is added to `taken`.""" + one for is added to `taken`. + + `back` is the line that puts back a `.gitmodules` gone from the work + tree, and whose file it puts back (`_file_put_back`), which the + section's own lines come after, the `git rm --cached` before it + (`_section_out`, #1036).""" path = keys["path"] if (path not in gitlinks or path in removed or _in_members(path) or path in taken): return _section_out(name, keys, "Otherwise take", - f"then commit {GITMODULES}") + f"then commit {GITMODULES}", back=back) taken.add(path) head = _forced(root, path, gitlinks[path]) in_head = _head_gitmodules(root) @@ -3279,7 +3331,7 @@ def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], + _forced_said(head, nth, _held_if_forced(root, path, head)) + "." + _section_out(name, keys, "Then, with the lines after it, take", then, (f"\n {STAGE_LINE}" if stage else "") - + f"\n {_cached_removal(path, head)}")) + + f"\n {_cached_removal(path, head)}", back)) def _section_lines(path: str, repository, @@ -3714,14 +3766,21 @@ def _blob_paths(root: Path, spec: str) -> set[str]: (`:.gitmodules`, the index's, or `HEAD:.gitmodules`), read as `_gitmodules_sections` reads the work tree's; none where git cannot read it (#879).""" + return {keys["path"] for keys in _blob_sections(root, spec).values() + if "path" in keys} + + +def _blob_sections(root: Path, spec: str) -> dict[str, dict[str, str]]: + """Every `submodule` section of the `.gitmodules` git names `spec`, + `{name: {key: value}}`, read as `_blob_paths` reads its paths, and none + where git cannot read it (#879, #1036).""" with _git_runs(f"{spec} could not be read as git reads it"): try: listed = git_out(["config", "-z", "--blob", spec, "--list"], cwd=root, binary=True) except Refusal: - return set() - return {keys["path"] for keys in _sections_listed(listed).values() - if "path" in keys} + return {} + return _sections_listed(listed) def _put_back_lines(root: Path, path: str, @@ -4025,6 +4084,15 @@ def _check_members(root: Path, members: list, no_members: bool, names: the finding fails the run, where `member-uninitialized` named the update that dies, and `_init_stop_said`, which would name a stop git never reaches there, is not asked. + + `gitmodules-path-unnamed` READS THE FILE GIT READS (#1036): where the + work tree's `.gitmodules` is gone from disk, the index's, then HEAD's + (`_sections_as_git_reads`), not nothing. Not where either file gives a + value git dies on: that finding's lines put HEAD's file back, take the + key out and stage the file, which drops every section the index's file + alone gave, so a section's `--unset-all` after them met a key the file + did not hold (exit 5), and it reads what it read before. Every other + reader of `sections` is as it was. """ _check_member_names_unique(members, report) _check_member_ids_unique(root, members, report) @@ -4047,7 +4115,9 @@ def _check_members(root: Path, members: list, no_members: bool, removed = _staged_removals(root, gitlinks) _check_gitlinks_named(root, gitlinks, named, removed, report) _check_gitlinks_elsewhere(root, gitlinks, named, removed, report) - _check_gitmodules_named(root, sections, gitlinks, named, removed, report) + _check_gitmodules_named(root, sections if refused + else _sections_as_git_reads(root, sections), + gitlinks, named, removed, report) unregistered = _check_gitlinks_registered(root, members, gitlinks, report, refused) unregistered |= _check_removals_staged(root, members, gitlinks, removed, diff --git a/tests/test_family_hand_mount_git_rm_lines.py b/tests/test_family_hand_mount_git_rm_lines.py index 191dd7d8..1fcedd36 100644 --- a/tests/test_family_hand_mount_git_rm_lines.py +++ b/tests/test_family_hand_mount_git_rm_lines.py @@ -57,6 +57,16 @@ in every mode, and a plain clone's `git submodule update --init --recursive` must check out both members. The helpers are #879's (`tests/test_family_holder_remedies_compose.py`) and those it imports. + +#1036, RV1025'S SHOULD 1 ON #1025: state 1's `git submodule add`, not +committed, with the work tree's `.gitmodules` then deleted. The section +the add staged is in the index's file alone, which git reads in place of +the one gone (`git submodule status members/IRQS` maps it, git 2.43), but +`gitmodules-path-unnamed` read the work tree's alone and named nothing: +every line typed exited 0, the gate passed, and the commit of what was +staged recorded the section. It reads the file git reads now, the index's, +then HEAD's, and its lines put that file back first (`git config -f` +on no file exits 5 and makes none). Its rows are at the end of this file. """ from __future__ import annotations @@ -75,15 +85,20 @@ commit_all, staged_and_committed, ) from test_family_holder_remedies_compose import ( - MODES, OTHER_ROW, a_file_at, cloning, committed_with_its_checkout, - ends_well, files_at, found_alike, head_paths, holding_a_file, - moved_back_and_added, moved_by_git_mv, moved_with_a_commit, - staged_by_hand, typed_in_order, + MODES, OTHER_ROW, PUT_BACK_THEN, a_file_at, cloning, codes_of, + committed_with_its_checkout, ends_well, files_at, found_alike, + head_paths, holding_a_file, moved_back_and_added, moved_by_git_mv, + moved_with_a_commit, staged_by_hand, typed_in_order, value_committed, ) from test_family_member_path import raw -from test_family_unnamed_gitlink import submodule_added -from test_validate_family_typed_states import GONE, ROW -from test_validate_family_unnamed_mounts import commands +from test_family_unnamed_gitlink import ( + FORCED_NO_HEAD, THEN_STAGE, TOOLS, section_first, submodule_added, +) +from test_validate_family_typed_states import GONE, ROW, gone +from test_validate_family_unnamed_mounts import ( + SECTION_REMEDY, SECTION_SAID, SECTION_UNDER_GIT_RM, VARIANT, commands, + removal, with_section, +) HAND = f"members/{SPARE}" @@ -531,3 +546,242 @@ def test_a_hand_mount_s_git_this_user_cannot_reach_is_refused_by_name( assert "Traceback" not in result.stderr assert "chmod u+rx" in result.stderr assert found_alike(holder, *MODES) == before + + +# --- #1036: a section the file git reads gives, the work tree's gone --------- + +def put_back_said(how: str) -> str: + """What `gitmodules-path-unnamed` says of the line it prints before a + section's keys where the work tree's `.gitmodules` is gone, as `how` + (`GONE`) says, ahead of what it says last (#1036).""" + whose = GONE[how][0] + return f"after the line that puts {PUT_BACK_THEN.format(whose=whose)}, " + + +def index_url(root: Path, env: dict, name: str) -> str: + """The url the section `name` of the index's `.gitmodules` gives.""" + return raw(root, env, "config", "--blob", ":.gitmodules", + f"submodule.{name}.url").stdout.decode("utf-8").strip() + + +def lines_of(*lines: str) -> str: + """`lines` as a finding prints them, one to an indented line.""" + return "".join(f"\n {line}" for line in lines) + + +def ghost_out(how: str, *, put_back: bool) -> str: + """`gitmodules-path-unnamed` whole for a section named `ghost` giving + `Members/IRRS`, a case variant of MEMBER's path, url + `/nowhere/ghost.git`, with the work tree's `.gitmodules` gone as `how` + (`GONE`) says: its keys' lines, after the line that puts the file back + where `put_back`.""" + line = GONE[how][1] + return (SECTION_SAID.format(path="Members/IRRS", name="ghost", + url="url /nowhere/ghost.git") + + VARIANT.format(row_path=ROW, project="IRRS") + + " Take the section out by hand, in a POSIX shell, every key it " + "holds, so a submodule later given its name inherits none of " + "them, " + (put_back_said(how) if put_back else "") + + "then commit .gitmodules:" + + lines_of(*([line] if put_back else []), + *removal("ghost", "path", "url"))) + + +def test_1036_a_section_the_index_alone_gives_comes_out_of_the_file_put_back( + family, holder, offline, tmp_path): + """#1036, RV1025's SHOULD 1 on #1025: `git submodule add` at + `members/IRQS`, not committed, then the work tree's `.gitmodules` + deleted, not staged (`MD .gitmodules`). The index's file, which git + reads in its place, gives IRQS's section, and `gitmodules-path-unnamed` + read the work tree's alone: the hand mount's `git rm --cached -f` exited + 0, the gate passed, and the commit of what was staged recorded the + section. The section is a finding now, in every mode, whose lines put + the index's file back first, then take every key out. Typed in order, + the checkout deleted as the text says, `.gitmodules` staged as the + section's text says, then what is staged committed: HEAD's `.gitmodules` + gives the two members alone, nothing is left, the gate passes in every + mode and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + path = added_not_committed(holder, env, family) + url = index_url(holder, env, path) + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + typed_around(holder, env, found, lambda: deleted(holder / path)) + if "gitmodules-path-unnamed" in codes_of(found): + raw(holder, env, "add", "--", ".gitmodules") + if raw(holder, env, "diff", "--cached", "--quiet", + check=False).returncode: + commit_all(holder, env, "What is staged") + assert head_paths(holder, env) == {ROW, OTHER_ROW} + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + assert codes_of(found) == ["member-gitlink-unnamed", + "gitmodules-path-unnamed"] + assert (commands(mount_of(found, path))[1] + == f"git rm --cached -f -- {path}") + put_back = GONE["deleted"][1] + assert found[1] == ( + SECTION_SAID.format(path=path, name=path, url=f"url {url}") + + SECTION_REMEDY.replace("then commit .gitmodules:", + put_back_said("deleted") + + "then commit .gitmodules:") + + lines_of(put_back, *removal(path, "path", "url"))) + + +def test_1036_outside_members_the_gitlink_goes_first_then_the_put_back( + family, holder, offline, tmp_path): + """#1036 outside `members/`: `git submodule add` at tools/x, not + committed, then the work tree's `.gitmodules` deleted. The index's file + gives tools/x its section, so `gitlink-unnamed` rightly says nothing, + and `gitmodules-path-unnamed` read the work tree's alone: the gate + passed, and the next commit recorded a gitlink and a section no row can + name. The section's finding takes the gitlink out of the index first, + then puts the index's file back, then takes every key out. Typed in + order and staged as it says, nothing is left to commit, the checkout + stays untracked, the gate passes in every mode and a plain clone checks + both out.""" + env = cloning(offline, tmp_path, family) + submodule_added(holder, env, family, TOOLS) + url = index_url(holder, env, TOOLS) + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + put_back = GONE["deleted"][1] + removed = f"\n git rm --cached -f -- {TOOLS}" + assert found == [section_first( + TOOLS, TOOLS, url, FORCED_NO_HEAD, + put_back_said("deleted") + THEN_STAGE, " -f").replace( + removed, removed + lines_of(put_back))] + typed_in_order(holder, env, validate(holder, "--no-members")) + raw(holder, env, "add", "--", ".gitmodules") + assert raw(holder, env, "status", "--porcelain").stdout == b"?? tools/\n" + ends_well(holder, env, family, tmp_path) + + +@pytest.mark.parametrize("how", GONE) +def test_1036_a_stray_section_comes_out_of_the_file_put_back( + family, holder, offline, tmp_path, how): + """#1036 in the case-variant branch: a section named `ghost` giving + `Members/IRRS`, a second path to MEMBER's directory on macOS and + Windows, committed, then the work tree's `.gitmodules` deleted or its + deletion staged. Git reads the index's file, or HEAD's, both of which + give it, and the gate passed. Its lines put that file back first now, + then take every key out. Typed in order, staged and committed as it + says, HEAD's `.gitmodules` gives the two members alone, the gate + passes in every mode and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + with_section(holder, tmp_path, "ghost", "Members/IRRS", + "/nowhere/ghost.git") + gone(holder, env, how) + + found = found_alike(holder, *MODES) + + assert found == [ghost_out(how, put_back=True)] + typed_in_order(holder, env, validate(holder, "--no-members")) + staged_and_committed(holder, env, "Typed in order") + assert raw(holder, env, "status", "--porcelain").stdout == b"" + assert head_paths(holder, env) == {ROW, OTHER_ROW} + ends_well(holder, env, family, tmp_path) + + +def test_1036_one_put_back_serves_two_sections(family, holder, offline, + tmp_path): + """#1036, two sections no row names, `ghost` giving `Members/IRRS` and + `members/Old` giving its own name, committed, then the work tree's + `.gitmodules` deleted. The first finding puts the index's file back + before its keys, and records that; the second prints its keys alone, + as with the file on disk: a second put-back, typed after the first + finding's lines, brought back the section they had just taken out. + Typed in order, staged and committed, HEAD's `.gitmodules` gives the + two members alone, the gate passes in every mode and a plain clone + checks both out.""" + env = cloning(offline, tmp_path, family) + with_section(holder, tmp_path, "ghost", "Members/IRRS", + "/nowhere/ghost.git") + with_section(holder, tmp_path, "members/Old", "members/Old", + "/nowhere/old.git") + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + assert found == [ + ghost_out("deleted", put_back=True), + SECTION_SAID.format(path="members/Old", name="members/Old", + url="url /nowhere/old.git") + + SECTION_REMEDY + lines_of(*removal("members/Old", "path", "url"))] + typed_in_order(holder, env, validate(holder, "--no-members")) + staged_and_committed(holder, env, "Typed in order") + assert raw(holder, env, "status", "--porcelain").stdout == b"" + assert head_paths(holder, env) == {ROW, OTHER_ROW} + ends_well(holder, env, family, tmp_path) + + +@pytest.mark.parametrize("how", GONE) +def test_1036_a_hand_mount_s_section_under_its_plain_git_rm_prints_nothing( + family, holder, offline, tmp_path, how): + """#1036 where the hand mount's plain `git rm` takes the section out: a + member mounted by hand with its checkout and its section, committed, + then the work tree's `.gitmodules` deleted or its deletion staged. The + section, which the file git reads gives, is named now, as with the file + on disk, and prints no command: the hand mount's text puts that file + back first and its `git rm` takes the section out of it (#936). Typed in + order and committed, nothing is left, HEAD's `.gitmodules` gives the two + members alone, the gate passes in every mode and a plain clone checks + both out.""" + env = cloning(offline, tmp_path, family) + path = committed_with_its_checkout(holder, env, family) + url = index_url(holder, env, path) + gone(holder, env, how) + + found = found_alike(holder, *MODES) + + assert codes_of(found) == ["member-gitlink-unnamed", + "gitmodules-path-unnamed"] + assert commands(mount_of(found, path))[1:3] == [GONE[how][1], + f"git rm -- {path}"] + assert found[1] == ( + SECTION_SAID.format(path=path, name=path, url=f"url {url}") + + SECTION_UNDER_GIT_RM.format(path=path)) + typed_in_order(holder, env, validate(holder, "--no-members")) + commit_all(holder, env, "Typed in order") + assert raw(holder, env, "status", "--porcelain").stdout == b"" + assert head_paths(holder, env) == {ROW, OTHER_ROW} + ends_well(holder, env, family, tmp_path) + + +def test_1036_control_a_value_git_dies_on_keeps_what_it_read( + family, holder, offline, tmp_path): + """#1036's control: `update = None` committed in IRSS's section, a value + git dies on, then IRQS's gitlink staged over a directory holding a file, + its section staged in the index's `.gitmodules` alone, and the work + tree's file deleted. The value's lines put HEAD's file back and stage + it, which drops that section, so `gitmodules-path-unnamed` reads nothing + from the file git reads there, as before: its `--unset-all` of a key the + file put back does not hold exited 5. Typed in order, what is on disk + deleted as the text says, then staged and committed as the value's + finding says, nothing is left, the gate passes in every mode and a + plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + value_committed(holder, env, OTHER_ROW) + path = staged_by_hand(holding_a_file)(holder, env, family) + data = (holder / ".gitmodules").read_bytes() + ( + f'[submodule "{path}"]\n\tpath = {path}\n' + f"\turl = https://github.com/InkRouter/{SPARE}.git\n").encode() + blob = raw(holder, env, "hash-object", "-w", "--stdin", + stdin=data).stdout.decode("ascii").strip() + raw(holder, env, "update-index", "--cacheinfo", + f"100644,{blob},.gitmodules") + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + assert codes_of(found) == ["gitmodules-value-refused", + "member-gitlink-unnamed"] + typed_around(holder, env, found, lambda: deleted(holder / path)) + staged_and_committed(holder, env, "Typed in order") + assert raw(holder, env, "status", "--porcelain").stdout == b"" + assert head_paths(holder, env) == {ROW, OTHER_ROW} + ends_well(holder, env, family, tmp_path) diff --git a/tests/test_family_holder_remedies_compose.py b/tests/test_family_holder_remedies_compose.py index b6e5effa..ee052fa6 100644 --- a/tests/test_family_holder_remedies_compose.py +++ b/tests/test_family_holder_remedies_compose.py @@ -49,7 +49,8 @@ `_stage_first`'s branches that print no `git add -- .gitmodules`, where the work tree holds no file and where the index holds none (SHOULD 1); `gitlink-unnamed` reading the paths from the file git reads where the -work tree's is gone (SHOULD 2); and `-f` over what is on disk at a staged +work tree's is gone (SHOULD 2, whose section is `gitmodules-path-unnamed` +since #1036 reads that file too); and `-f` over what is on disk at a staged gitlink and is no checkout, a directory holding a file or a file, and none over an empty directory, which git takes the plain line over (NIT 2). NIT 3's clause is in `STAGE_SAID`, which the C3 rows pin. @@ -113,17 +114,17 @@ from test_family_remove_gitmodules import verb_args from test_family_unnamed_gitlink import ( COMMIT_THAT, FORCED_NO_HEAD, RENAMED, STAGE_LINE, STAGE_SAID, TAKE_OUT, - TOOLS, assert_a_plain_clone_checks_out_every_member, at_tools, - found_in_every_mode, outside, section_taken_out_alone, staged_at, - submodule_added, + THEN_COMMIT, TOOLS, assert_a_plain_clone_checks_out_every_member, + at_tools, found_in_every_mode, outside, section_first, + section_taken_out_alone, staged_at, submodule_added, ) from test_validate_family_typed_states import ( GONE, ROW, SECTION_GIVEN_BACK, URL_ABSENT, VARIANT_PATH, gone, unnamed_as_moved, ) from test_validate_family_unnamed_mounts import ( - GITLINK_SAID, GITLINK_SAID_FETCHED, VARIANT, codes, commands, findings, - spare_commits, stage_gitlink, + GITLINK_SAID, GITLINK_SAID_FETCHED, SECTION_SAID, VARIANT, WRONG_CASE, + codes, commands, findings, spare_commits, stage_gitlink, ) MODES = ((), ("--pins",), ("--no-members",)) @@ -238,12 +239,15 @@ def test_c1_one_put_back_serves_the_move_and_a_url_finding_after_it( `--no-members` the move prints first, and `member-gitmodules-url-absent` after it put back the file the move had just staged, one key in it. The move puts the file back first now, and the url finding puts nothing - back. Typed in order and committed, the gate passes in every mode and a - plain clone checks both out.""" + back. The section of the file git reads that gives `Members/IRRS` is + named between them, as with the file on disk, and prints no command + (#1036). Typed in order and committed, the gate passes in every mode + and a plain clone checks both out.""" env = cloning(offline, tmp_path, family) commit = moved_in_head(holder, env) data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout committed_gitmodules(holder, env, without_url(data, OTHER)) + url = url_of(holder, env, ROW) gone(holder, env, how) result = validate(holder, "--no-members") @@ -252,6 +256,8 @@ def test_c1_one_put_back_serves_the_move_and_a_url_finding_after_it( assert findings(result) == [ unnamed_as_moved(VARIANT_PATH, commit, moved_refused(commit, how), said=GITLINK_SAID_FETCHED), + SECTION_SAID.format(path=VARIANT_PATH, name=ROW, url=f"url {url}") + + WRONG_CASE.format(row_path=ROW, project=MEMBER, what="section"), URL_ABSENT.format(commit=pinned(holder, OTHER))] typed_in_order(holder, env, result) staged_and_committed(holder, env, "Typed in order") @@ -827,12 +833,33 @@ def test_rv882_should_2_a_gitlink_the_file_git_reads_gives_is_no_finding( The index's or HEAD's file, which git reads in its place, gives tools/x a section, and a plain clone checks it out, while `gitlink-unnamed` said that no section gave it. The paths are read from the file git - reads now: no mode finds `gitlink-unnamed`, and the gate passes.""" + reads now: no mode finds `gitlink-unnamed`. + + IT SAID THE GATE PASSED, which it did only because + `gitmodules-path-unnamed` read the work tree's file alone (#1036): with + the file on disk the same state is that finding + (`tests/test_family_unnamed_gitlink.py`'s `tools-x` form). It is that + finding now, in every mode: the gitlink out first, then the file put + back, then the section's keys. Typed as printed, staged and committed, + HEAD's `.gitmodules` gives the two members alone, the gate passes in + every mode and a plain clone checks both out.""" env = cloning(offline, tmp_path, family) at_tools(holder, env, family) + url = url_of(holder, env, TOOLS) gone(holder, env, how) - assert_passes_in_every_mode(holder, 2) + finding = found_in_every_mode(holder, "gitmodules-path-unnamed") + + whose, line = GONE[how] + removed = f"\n git rm --cached -- {TOOLS}" + assert finding == section_first( + TOOLS, TOOLS, url, COMMIT_THAT, + f"after the line that puts {PUT_BACK_THEN.format(whose=whose)}, " + + THEN_COMMIT).replace(removed, f"{removed}\n {line}") + typed(holder, env, finding) + staged_and_committed(holder, env, "Typed as printed") + assert head_paths(holder, env) == {ROW, OTHER_ROW} + ends_well(holder, env, family, tmp_path) def on_disk_under(root: Path, path: str) -> list[str]: From 8d14a20e4ada8516338ed0df83e4f976eb52007e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 21:19:30 +0000 Subject: [PATCH 7/7] validate-family.py: `gitmodules-path-unnamed` stages `.gitmodules` with a printed line where HEAD's file holds no section of the name it takes out, in `members/` and in another case as at a gitlink outside it, so `git commit` after the lines records nothing of a section `git submodule add` staged (#1036) RV1069's SHOULD 1 on #1069. In #1036's state the section's text ended "then commit .gitmodules" and printed no stage line: after the put-back and the `--unset-all` lines the work tree's file was HEAD's, the index's still held the section (`MM .gitmodules`), so `git commit` recorded it and `git commit -- .gitmodules` had nothing to commit. `_section_then` gives every branch one ending, from HEAD's file: the commit where it holds a section of that name, else "then stage .gitmodules: HEAD's holds no section of that name, so nothing of it is left to commit", the words the branch for a gitlink outside `members/` already said, now with `git add -- .gitmodules` printed after the keys' lines (`_section_out`), as the removal's written-back section and the wrong-case move print theirs. Rows: R1 and its tools/x sibling type only the printed lines, then `git commit` of what is staged; both fail on ab8ed9e5 (HEAD keeps `members/IRQS`; the stage line is missing) and pass here. `tests/test_family_unnamed_gitlink.py`'s `section_first` expects the stage line where the text ends "then stage". Co-Authored-By: Claude Opus 5.5 --- .../family-root/scripts/validate-family.py | 47 ++++++++++++++----- tests/test_family_hand_mount_git_rm_lines.py | 44 ++++++++++------- tests/test_family_unnamed_gitlink.py | 3 +- 3 files changed, 63 insertions(+), 31 deletions(-) diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index 4f09960f..655c8cca 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -3203,9 +3203,10 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], said + _variant_advice(kind, path, *variant, "section")) continue + then, staged = _section_then(root, name) said += _variant_said(*variant) + _section_out( - name, keys, "Take", f"then commit {GITMODULES}", - back=_file_put_back(root, report)) + name, keys, "Take", then, back=_file_put_back(root, report), + staged=staged) elif (path in gitlinks and path not in removed and given[path] == 1 and _in_members(path) and _hand_cached(root, path, gitlinks[path]) is None): @@ -3240,7 +3241,8 @@ def _section_unnamed_said(name: str, keys: dict[str, str]) -> str: def _section_out(name: str, keys: dict[str, str], lead: str, then: str, - line: str = "", back: tuple[str, str] = ("", "")) -> str: + line: str = "", back: tuple[str, str] = ("", ""), + staged: bool = False) -> str: """The end of `gitmodules-path-unnamed`'s text, which takes the section `name` out by key (#300), every key it holds (`keys`), after `lead`, ending `then`, with `line`, a `git rm` printed first, before those @@ -3252,10 +3254,19 @@ def _section_out(name: str, keys: dict[str, str], lead: str, then: str, said: `git config -f` on no file exits 5 and makes none, measured on git 2.43, so the keys' lines took nothing out (#1036). A `git rm --cached` takes the gitlink out with no file on disk, so `line` stays - first.""" + first. + + `staged`, where HEAD's `.gitmodules` holds no section of `name` + (`_section_then`), prints `git add -- .gitmodules` after the keys' + lines: the commit records the index's file, which still held the + section `git submodule add` staged, so a `git commit` after the lines + recorded it, and `git commit -- .gitmodules` had nothing to commit + (RV1069's SHOULD 1 on #1069).""" removal = "".join( f"\n git config -f {GITMODULES} --unset-all " f"{shlex.quote(f'submodule.{name}.{key}')}" for key in keys) + if staged: + removal += f"\n {STAGE_LINE}" put_back, whose = back if put_back: then = (f"after the line that puts {GITMODULES}, gone from the work " @@ -3267,6 +3278,20 @@ def _section_out(name: str, keys: dict[str, str], lead: str, then: str, f"them, {then}:{line}{removal}") +def _section_then(root: Path, name: str) -> tuple[str, bool]: + """How `gitmodules-path-unnamed`'s text for the section `name` ends, + and whether its lines end staging `.gitmodules` (`_section_out`): the + commit where HEAD's file holds a section of that name, else the file + staged, with nothing of it left to commit, as after a `git submodule + add` not committed (RV1069's SHOULD 1 on #1069). Every branch asks + it, in `members/`, outside it and in another case.""" + in_head = _head_gitmodules(root) + if in_head is not None and name in in_head[1]: + return f"then commit {GITMODULES}", False + return (f"then stage {GITMODULES}: HEAD's holds no section of that name, " + "so nothing of it is left to commit"), True + + def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], gitlinks: dict[str, str], removed: frozenset[str], taken: set[str], edited: bool, @@ -3312,15 +3337,12 @@ def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], path = keys["path"] if (path not in gitlinks or path in removed or _in_members(path) or path in taken): - return _section_out(name, keys, "Otherwise take", - f"then commit {GITMODULES}", back=back) + then, staged = _section_then(root, name) + return _section_out(name, keys, "Otherwise take", then, back=back, + staged=staged) taken.add(path) head = _forced(root, path, gitlinks[path]) - in_head = _head_gitmodules(root) - then = (f"then commit {GITMODULES}" - if in_head is not None and name in in_head[1] else - f"then stage {GITMODULES}: HEAD's holds no section of that name, " - "so nothing of it is left to commit") + then, staged = _section_then(root, name) stage = _stage_first(root, edited) nth = "the second line" if stage else "the first line" return (" Otherwise take its gitlink out first, since a gitlink no " @@ -3332,7 +3354,8 @@ def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], + _forced_said(head, nth, _held_if_forced(root, path, head)) + "." + _section_out(name, keys, "Then, with the lines after it, take", then, (f"\n {STAGE_LINE}" if stage else "") - + f"\n {_cached_removal(path, head)}", back)) + + f"\n {_cached_removal(path, head)}", back, + staged)) def _section_lines(path: str, repository, diff --git a/tests/test_family_hand_mount_git_rm_lines.py b/tests/test_family_hand_mount_git_rm_lines.py index 1fcedd36..c1d90ac1 100644 --- a/tests/test_family_hand_mount_git_rm_lines.py +++ b/tests/test_family_hand_mount_git_rm_lines.py @@ -564,6 +564,14 @@ def index_url(root: Path, env: dict, name: str) -> str: f"submodule.{name}.url").stdout.decode("utf-8").strip() +def what_is_staged_committed(root: Path, env: dict) -> None: + """`git commit` of what is staged, typed by the person after the + printed lines, the issue's Verify, and nothing else: no `git add` the + text does not print (RV1069's SHOULD 1 on #1069). Where nothing is + staged it exits 1, `nothing to commit`, and HEAD is as it was.""" + raw(root, env, "commit", "-q", "-m", "What is staged", check=False) + + def lines_of(*lines: str) -> str: """`lines` as a finding prints them, one to an indented line.""" return "".join(f"\n {line}" for line in lines) @@ -596,11 +604,14 @@ def test_1036_a_section_the_index_alone_gives_comes_out_of_the_file_put_back( read the work tree's alone: the hand mount's `git rm --cached -f` exited 0, the gate passed, and the commit of what was staged recorded the section. The section is a finding now, in every mode, whose lines put - the index's file back first, then take every key out. Typed in order, - the checkout deleted as the text says, `.gitmodules` staged as the - section's text says, then what is staged committed: HEAD's `.gitmodules` - gives the two members alone, nothing is left, the gate passes in every - mode and a plain clone checks both out.""" + the index's file back first, take every key out, then stage the file, + since HEAD's holds no section of that name (RV1069's SHOULD 1 on #1069: + the text said "then commit .gitmodules", the index's file kept the + section, and a `git commit` recorded it). Only the printed lines typed, + in order, the checkout deleted as the text says, then `git commit` of + what is staged, the issue's Verify: HEAD's `.gitmodules` gives the two + members alone, nothing is left, the gate passes in every mode and a + plain clone checks both out.""" env = cloning(offline, tmp_path, family) path = added_not_committed(holder, env, family) url = index_url(holder, env, path) @@ -609,11 +620,7 @@ def test_1036_a_section_the_index_alone_gives_comes_out_of_the_file_put_back( found = found_alike(holder, *MODES) typed_around(holder, env, found, lambda: deleted(holder / path)) - if "gitmodules-path-unnamed" in codes_of(found): - raw(holder, env, "add", "--", ".gitmodules") - if raw(holder, env, "diff", "--cached", "--quiet", - check=False).returncode: - commit_all(holder, env, "What is staged") + what_is_staged_committed(holder, env) assert head_paths(holder, env) == {ROW, OTHER_ROW} assert raw(holder, env, "status", "--porcelain").stdout == b"" ends_well(holder, env, family, tmp_path) @@ -625,9 +632,9 @@ def test_1036_a_section_the_index_alone_gives_comes_out_of_the_file_put_back( assert found[1] == ( SECTION_SAID.format(path=path, name=path, url=f"url {url}") + SECTION_REMEDY.replace("then commit .gitmodules:", - put_back_said("deleted") - + "then commit .gitmodules:") - + lines_of(put_back, *removal(path, "path", "url"))) + put_back_said("deleted") + THEN_STAGE + ":") + + lines_of(put_back, *removal(path, "path", "url"), + "git add -- .gitmodules")) def test_1036_outside_members_the_gitlink_goes_first_then_the_put_back( @@ -638,10 +645,10 @@ def test_1036_outside_members_the_gitlink_goes_first_then_the_put_back( and `gitmodules-path-unnamed` read the work tree's alone: the gate passed, and the next commit recorded a gitlink and a section no row can name. The section's finding takes the gitlink out of the index first, - then puts the index's file back, then takes every key out. Typed in - order and staged as it says, nothing is left to commit, the checkout - stays untracked, the gate passes in every mode and a plain clone checks - both out.""" + then puts the index's file back, takes every key out and stages the + file. Only the printed lines typed, then `git commit` of what is staged, + nothing is left to commit, the checkout stays untracked, the gate passes + in every mode and a plain clone checks both out.""" env = cloning(offline, tmp_path, family) submodule_added(holder, env, family, TOOLS) url = index_url(holder, env, TOOLS) @@ -656,7 +663,8 @@ def test_1036_outside_members_the_gitlink_goes_first_then_the_put_back( put_back_said("deleted") + THEN_STAGE, " -f").replace( removed, removed + lines_of(put_back))] typed_in_order(holder, env, validate(holder, "--no-members")) - raw(holder, env, "add", "--", ".gitmodules") + what_is_staged_committed(holder, env) + assert head_paths(holder, env) == {ROW, OTHER_ROW} assert raw(holder, env, "status", "--porcelain").stdout == b"?? tools/\n" ends_well(holder, env, family, tmp_path) diff --git a/tests/test_family_unnamed_gitlink.py b/tests/test_family_unnamed_gitlink.py index 4450822d..8dd6db02 100644 --- a/tests/test_family_unnamed_gitlink.py +++ b/tests/test_family_unnamed_gitlink.py @@ -167,7 +167,8 @@ def section_first(path: str, name: str, url: str, said: str, then: str, + SECTION_THEN.format(then=then) + (STAGE_LINE if stage else "") + f"\n git rm --cached{flag} -- {path}" + "".join(f"\n {line}" for line in removal(name, "path", - "url"))) + "url")) + + (STAGE_LINE if then.endswith(THEN_STAGE) else "")) def found_in_every_mode(root: Path, code: str) -> str: