From 1d1499337f2aad2d71188bb9f3fd833cb3463b31 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 00:53:17 +0000 Subject: [PATCH 1/4] siblings.py: a file, or a folder `make siblings` cannot list or enter, at `../` is NOT A CLONE, reported and left as it is, so the run no longer ends in a traceback at `iterdir` before the members after it are placed, and `make park` no longer ends in one over a folder it cannot enter (#735) `place` asked only whether a folder had something in it: `iterdir` raised `NotADirectoryError` for a file and `PermissionError` for a folder with mode 0311 or 000, a traceback, exit 1. `not_a_clone` now gives the finding's words for each slot nothing is cloned into: the folder of files keeps its finding byte for byte, a file "is a file, not a folder", and a folder it cannot list "is a folder that cannot be listed", the words the doctor's `members` row says of the same slots. `holds_git` asks for a `.git` and reads an `OSError` as none, for `place` and for `dispatch` (`--make`), which Python 3.12 answered with `PermissionError` in a folder that cannot be entered. The doctor's agreement test (#705) now passes for a file and for both locked folders, so its strict xfail is gone; the doctor's output is unchanged, and its comments now say a holder pinned before this change still ends in the traceback. `siblings.py` is a family-root copy carried by digest: a holder pinned before this change shows an `upstream-changed` row for it. Co-Authored-By: Claude Opus 5.5 --- shape-doctor.py | 22 +++--- templates/family-root/scripts/siblings.py | 54 +++++++++++++-- tests/test_family.py | 81 +++++++++++++++++++++++ tests/test_shape_doctor.py | 51 +++++++------- 4 files changed, 163 insertions(+), 45 deletions(-) diff --git a/shape-doctor.py b/shape-doctor.py index befe2808..05d2113b 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -8009,11 +8009,12 @@ def sibling_slot(root: Path, sibling: Path, row: dict, only names. A FILE, OR A FOLDER THAT CANNOT BE LISTED OR ENTERED, IS `not-a-clone` - TOO: nothing can be cloned into it. `make siblings` itself ends in a - traceback on each today (`siblings.py:558`, #735), so the row says what - is there and nothing of what `make siblings` does with it - (`beside_sentence`). A folder that cannot be entered cannot be asked - for its `.git` either, so that question's `OSError` reads "no `.git`". + TOO: nothing can be cloned into it. A holder whose pinned `siblings.py` + predates #735 ends `make siblings` in a traceback on each, and one that + has #735 calls it NOT A CLONE, so the row says what is there and + nothing of what `make siblings` does with it (`beside_sentence`), which + is true of both. A folder that cannot be entered cannot be asked for its + `.git` either, so that question's `OSError` reads "no `.git`". """ if sibling == root: return "is-the-holder", None @@ -8158,9 +8159,9 @@ def url_less_members(root: Path, rows: list) -> list[str]: "project.yaml that says it is this member", ""), } -#: What stands at a `not-a-clone` slot `make siblings` cannot read today, as -#: `unclonable` names it: it ends in a traceback on each (#735), so the row -#: says what is there and no word of what `make siblings` does with it. +#: What stands at a `not-a-clone` slot that a holder's pinned `siblings.py` +#: from before #735 ends in a traceback on, as `unclonable` names it: the +#: row says what is there and no word of what `make siblings` does with it. UNREADABLE_BESIDE = {"file": "is a file, not a folder", "unlistable": "is a folder that cannot be listed"} @@ -8177,8 +8178,9 @@ def beside_sentence(ctx: Context, entry: dict) -> str: `--json` keeps them raw: `json.dumps` escapes them. A FILE, OR A FOLDER THAT CANNOT BE LISTED, is `not-a-clone` with no - word from `make siblings`, which ends in a traceback on it today (#735): - the sentence stops at what stands there (`UNREADABLE_BESIDE`). + word from `make siblings`, which ends in a traceback on it in a holder + whose pinned copy predates #735: the sentence stops at what stands + there (`UNREADABLE_BESIDE`). """ state = entry["sibling"] where = ctx.root.parent / entry["project"] diff --git a/templates/family-root/scripts/siblings.py b/templates/family-root/scripts/siblings.py index c41e41f6..c39f1d41 100755 --- a/templates/family-root/scripts/siblings.py +++ b/templates/family-root/scripts/siblings.py @@ -457,6 +457,45 @@ def project_id(path: Path) -> str | None: return str(data.get("id")) if isinstance(data, dict) else None +def holds_git(path: Path) -> bool: + """Does `path` hold a `.git`? False where that cannot be asked (#735): + in a folder that cannot be entered (mode `000`), Python 3.12's + `Path.exists` raises `PermissionError`, and `make siblings` and `make + park` ended in that traceback.""" + try: + return (path / ".git").exists() + except OSError: + return False + + +def not_a_clone(target: Path) -> tuple[str, str] | None: + """`(what stands at target, what kind of thing it is)` for a `target` + with no `.git` that this command will not write into, in the words its + `NOT A CLONE` finding says it in; None for nothing at all, or an empty + folder, which a clone goes into. + + A FILE, OR A FOLDER THAT CANNOT BE LISTED, IS NOT A CLONE TOO (#735). + Only a folder with something in it was asked about, and `iterdir` + raised on the other two: `NotADirectoryError` for a file, and + `PermissionError` for a folder that can be entered and not listed (mode + `0311`; `711` is common for another user's directory) or cannot be + entered at all. Each ended `make siblings` in a traceback, exit 1, and + the members after it were never placed. Nothing can tell that such a + folder is empty, and `git clone` refuses one it cannot read. The words + are the ones the doctor's `members` row says of the same two slots. + """ + try: + if not target.exists(): + return None + if not target.is_dir(): + return "is a file, not a folder", "file" + if any(target.iterdir()): + return "exists, is not empty and is not a git clone", "directory" + return None + except OSError: + return "is a folder that cannot be listed", "directory" + + def verify_sibling(row: dict, target: Path, url: str, repository: str, sibling: Sibling) -> bool: """Is the clone at `target` the member this row names? @@ -554,14 +593,15 @@ def place(root: Path, row: dict, urls: dict[str, str], prefix: list[str], f"the two are not the same path, then re-run.") return sibling - present = (target / ".git").exists() - if not present and target.exists() and any(target.iterdir()): + present = holds_git(target) + occupied = None if present else not_a_clone(target) + if occupied: + found, thing = occupied sibling.state = "NOT A CLONE" sibling.finding = ( - f"{who}: {at} exists, is not empty and is not a git " - "clone, so this command will not write into it. Move it aside or " - "name the directory something else; nothing here overwrites a " - "directory somebody made.") + f"{who}: {at} {found}, so this command will not write into it. " + f"Move it aside or name the {thing} something else; nothing here " + f"overwrites a {thing} somebody made.") return sibling if not present: @@ -725,7 +765,7 @@ def dispatch(root: Path, row: dict, urls: dict[str, str], target: str, "holder keeps its name) so the two are not the same path.") return sibling - if not (path / ".git").exists(): + if not holds_git(path): sibling.state = "SKIPPED no clone" sibling.finding = ( f"{who}: skipped: no working clone at {at}; run `make " diff --git a/tests/test_family.py b/tests/test_family.py index 960daf90..8e0df3bf 100644 --- a/tests/test_family.py +++ b/tests/test_family.py @@ -1910,6 +1910,87 @@ def test_siblings_reports_a_directory_that_is_not_a_clone_at_all(estate): assert (estate["folder"] / "IRRS" / "notes.md").read_text() == "mine\n" +#: A mode hides a folder's contents only on POSIX, and never from root. +MODE_HOLDS = pytest.mark.skipif( + os.name == "nt" or getattr(os, "geteuid", lambda: 0)() == 0, + reason="a mode hides a folder's contents only on POSIX, from a user " + "who is not root") + +#: `(the folder's mode, or None for a file; what the finding says is there; +#: what kind of thing it is)` for the slots `make siblings` ended in a +#: traceback on (#735): a file, and a folder holding a file whose mode hides +#: it, one that can be entered and not listed (`711` is common for another +#: user's directory) and one that cannot be entered. +SLOTS_NOT_READ = [ + pytest.param(None, "is a file, not a folder", "file", id="a-file"), + pytest.param(0o311, "is a folder that cannot be listed", "directory", + id="a-folder-it-cannot-list", marks=MODE_HOLDS), + pytest.param(0o000, "is a folder that cannot be listed", "directory", + id="a-folder-it-cannot-enter", marks=MODE_HOLDS), +] + + +def slot_not_read(request, slot, mode): + """A file at `slot`, or, for a `mode`, a folder there holding `notes.md` + with that mode, given its own mode back when the test ends; the file + that says `mine` is returned.""" + if mode is None: + slot.write_text("mine\n", encoding="utf-8") + return slot + slot.mkdir() + (slot / "notes.md").write_text("mine\n", encoding="utf-8") + slot.chmod(mode) + request.addfinalizer(lambda: slot.chmod(0o755)) + return slot / "notes.md" + + +@pytest.mark.parametrize("mode, found, thing", SLOTS_NOT_READ) +def test_siblings_reports_a_slot_it_cannot_read_as_not_a_clone( + estate, request, mode, found, thing): + """#735: A FILE, OR A FOLDER IT CANNOT LIST, IS NOT A CLONE. + + `place` asked only whether a folder had something in it, and `iterdir` + raised on these: `NotADirectoryError` for a file, `PermissionError` for + the two folders. `make siblings` ended in that traceback, exit 1, and + IRSS, the member after it, was never placed. Now each is `NOT A CLONE`, + as the doctor's `members` row calls the slot: the finding is the one a + folder of files gets, saying what stands there, the slot is left as it + is, and the next member is placed.""" + slot = estate["folder"] / "IRRS" + mine = slot_not_read(request, slot, mode) + result = siblings(estate["holder"]) + assert "Traceback" not in result.stderr, result.stderr + assert result.returncode == 1, result.stdout + result.stderr + assert "sibling IRRS NOT A CLONE -" in layout(result.stdout), \ + result.stdout + where = estate["holder"].resolve().parent / "IRRS" + assert (f"FINDING IRRS: {where} {found}, so this command will not write " + f"into it. Move it aside or name the {thing} something else; " + f"nothing here overwrites a {thing} somebody made.") in \ + result.stderr, result.stderr + assert (estate["folder"] / "IRSS" / ".git").is_dir() + if mode is not None: + slot.chmod(0o755) + assert mine.read_text(encoding="utf-8") == "mine\n" + assert not (slot / ".git").exists() + + +@MODE_HOLDS +@pytest.mark.skipif(shutil.which("make") is None, + reason="needs `make` on PATH") +def test_a_make_verb_skips_a_folder_it_cannot_enter(estate, request): + """#735's `--make` half: `dispatch` asked `(path / ".git").exists()`, + which Python 3.12 answers with `PermissionError` in a folder that cannot + be entered, so `make park` ended in that traceback. It is now a member + with no working clone, skipped by name, as every other one is.""" + slot_not_read(request, estate["folder"] / "IRRS", 0o000) + result = siblings(estate["holder"], "--make", "park", "--dry-run") + assert "Traceback" not in result.stderr, result.stderr + assert "sibling IRRS SKIPPED no clone -" in [ + " ".join(line.split()) for line in result.stdout.splitlines()], \ + result.stdout + + def test_siblings_warns_about_the_parent_folder_and_moves_nothing(family, tmp_path): """THE DOCTOR PATTERN, NOT A MOVER (#76). The holder here sits under a diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index e5f99ca3..fdf157fa 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -4002,9 +4002,9 @@ def test_the_stranger_note_says_from_which_folder_make_siblings_calls_it( "working-clone": "each with a working clone beside the holder", } -#: What the row says instead of a `not-a-clone` slot `make siblings` ends in -#: a traceback on today (#735): what is there, and nothing of what `make -#: siblings` does with it. +#: What the row says instead of a `not-a-clone` slot that a holder's pinned +#: `siblings.py` from before #735 ends in a traceback on: what is there, and +#: nothing of what `make siblings` does with it. UNREADABLE_SAYS = { "file": "is a file, not a folder, so it is not counted as its working " "clone", @@ -4058,9 +4058,15 @@ def test_the_stranger_note_says_from_which_folder_make_siblings_calls_it( pytest.param("clone", "working-clone", id="the-working-clone"), ] -#: A file at `../IRRS`, which `make siblings` ends in a traceback on today -#: (#735). -A_FILE = ("file", "not-a-clone") +#: A file at `../IRRS`, and the two `LOCKED` folders: `not-a-clone`, and the +#: slots `make siblings` ended in a traceback on before #735. +UNREADABLE_SLOTS = [ + pytest.param("file", "not-a-clone", id="a-file"), + pytest.param("unlistable", "not-a-clone", id="a-folder-it-cannot-list", + marks=LOCKED_HOLDS), + pytest.param("unenterable", "not-a-clone", + id="a-folder-it-cannot-enter", marks=LOCKED_HOLDS), +] def holder_with_slot(holder: dict, tmp_path: Path, slot: str) -> Path: @@ -4109,14 +4115,7 @@ def locked(request, path: Path, slot: str) -> None: request.addfinalizer(lambda: path.chmod(0o755)) -@pytest.mark.parametrize("slot, state", [ - *SLOTS, - pytest.param(*A_FILE, id="a-file"), - pytest.param("unlistable", "not-a-clone", id="a-folder-it-cannot-list", - marks=LOCKED_HOLDS), - pytest.param("unenterable", "not-a-clone", - id="a-folder-it-cannot-enter", marks=LOCKED_HOLDS), -]) +@pytest.mark.parametrize("slot, state", [*SLOTS, *UNREADABLE_SLOTS]) def test_the_row_says_what_make_siblings_does_with_each_slot( standard, holder, tmp_path, request, slot, state): """#669: ONE ROW PER SLOT STATE. `--json` carries the state as @@ -4127,10 +4126,10 @@ def test_the_row_says_what_make_siblings_does_with_each_slot( workstation's layout, and `without_working_clone` keeps its meaning. A FILE, OR A FOLDER THAT CANNOT BE LISTED, is `not-a-clone`, and the - row says nothing of `make siblings` there, which ends in a traceback on - it today (#735). The folder was the review's MUST on #705: `iterdir`'s - `PermissionError` made the row a FINDING and the holder DRIFTED, exit - 1, where main says COMPLIANT, exit 0.""" + row says nothing of `make siblings` there, which a holder's pinned copy + from before #735 ends in a traceback on. The folder was the review's + MUST on #705: `iterdir`'s `PermissionError` made the row a FINDING and + the holder DRIFTED, exit 1, where main says COMPLIANT, exit 0.""" root = holder_with_slot(holder, tmp_path, slot) locked(request, root.parent / FAMILY_MEMBER, slot) result = doctor(standard, root, "--json") @@ -4149,24 +4148,20 @@ def test_the_row_says_what_make_siblings_does_with_each_slot( assert json.loads(result.stdout)["verdict"] == "COMPLIANT", result.stdout -@pytest.mark.parametrize("slot, state", [ - *SLOTS, - pytest.param(*A_FILE, id="a-file", marks=pytest.mark.xfail( - strict=True, raises=AssertionError, - reason="#735: `siblings.py:558` raises NotADirectoryError on a " - "file, so `make siblings` ends in a traceback")), -]) +@pytest.mark.parametrize("slot, state", [*SLOTS, *UNREADABLE_SLOTS]) def test_make_siblings_dry_run_gives_the_rows_word_for_each_slot( - standard, holder, tmp_path, slot, state): + standard, holder, tmp_path, request, slot, state): """#669: THE AGREEMENT. The holder's own `scripts/siblings.py --dry-run`, run from its family folder, says of the member what the row's `sibling` state says it does: the same word where it refuses the directory, `would clone` where the row says it places it, and `present, would fetch` where it fetches it as the member. The row restates `place`'s order, and this is what keeps the two tools answering - together about one directory (#146). A FILE is held to it too, as a - strict xfail until #735 makes `make siblings` answer NOT A CLONE.""" + together about one directory (#146). A FILE, and a folder that cannot + be listed or entered, are held to it too: `make siblings` ended in a + traceback on each, and since #735 calls each NOT A CLONE.""" root = holder_with_slot(holder, tmp_path, slot) + locked(request, root.parent / FAMILY_MEMBER, slot) entry = rows_of(doctor(standard, root, "--json"))["members"]["detail"][ "members"][0] assert entry["sibling"] == state, entry From e966a684a7b313abfe3c58b5aefa1724e1f1a0de Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 05:03:33 +0000 Subject: [PATCH 2/4] siblings.py and shape-doctor.py: a link to nothing at `../` is NOT A CLONE, reported and left as it is, so the dry run no longer says `would clone` where the real run ends CLONE FAILED, and the doctor's `members` row no longer calls the slot `absent` and says `make siblings` places the member there (#784) `exists` follows a link, so a dangling one read as nothing at all. In `siblings.py`, `not_a_clone` now answers "is a link to nothing" for it, in the finding the other NOT A CLONE slots get. In the doctor, `unclonable` answers `dangling`, so `sibling_slot` says `not-a-clone`, and the row says "is a link to nothing" and nothing of what `make siblings` does there: a holder pinned before this change still ends CLONE FAILED on it. `make park` and `make resume` keep `SKIPPED no clone`. `siblings.py` is a family-root copy carried by digest: a holder pinned before this change shows an `upstream-changed` row for it. Co-Authored-By: Claude Opus 5.5 --- shape-doctor.py | 45 ++++++---- templates/family-root/scripts/siblings.py | 8 ++ tests/test_family.py | 100 +++++++++++++++------- tests/test_shape_doctor.py | 20 ++++- 4 files changed, 120 insertions(+), 53 deletions(-) diff --git a/shape-doctor.py b/shape-doctor.py index 05d2113b..fc59f79c 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -7974,8 +7974,9 @@ def working_clone(sibling: Path, row: dict, def unclonable(sibling: Path) -> str | None: """What stands at `sibling`, a path with no `.git` of its own, that nothing can be cloned into: `files` (a folder with something in it), - `file` (anything that is not a folder) or `unlistable` (a folder this - process cannot list). None for nothing at all, or an empty folder. + `file` (anything that is not a folder), `unlistable` (a folder this + process cannot list) or `dangling` (a link to nothing, #784). None for + nothing at all, or an empty folder. A FOLDER THAT CANNOT BE LISTED IS NOT EMPTY (#669). A directory that can be entered and not listed (mode `0311`, or `711`, which is common @@ -7984,10 +7985,15 @@ def unclonable(sibling: Path) -> str | None: not be run, so a COMPLIANT holder read DRIFTED, exit 1. Nobody can say it is empty, and `git clone` refuses a folder it cannot read, so it is a slot nothing is cloned into. + + A LINK TO NOTHING IS NOT NOTHING (#784). `exists` follows a link, so a + dangling one read as `absent`, and the row said `make siblings` places + the member there, where git ends `CLONE FAILED`: it cannot clone into a + path that is a link to nothing. """ try: if not sibling.exists(): - return None + return "dangling" if sibling.is_symlink() else None if not sibling.is_dir(): return "file" return "files" if any(sibling.iterdir()) else None @@ -8008,13 +8014,15 @@ def sibling_slot(root: Path, sibling: Path, row: dict, the parent-folder rule `make siblings` asks before either, which the row only names. - A FILE, OR A FOLDER THAT CANNOT BE LISTED OR ENTERED, IS `not-a-clone` - TOO: nothing can be cloned into it. A holder whose pinned `siblings.py` - predates #735 ends `make siblings` in a traceback on each, and one that - has #735 calls it NOT A CLONE, so the row says what is there and - nothing of what `make siblings` does with it (`beside_sentence`), which - is true of both. A folder that cannot be entered cannot be asked for its - `.git` either, so that question's `OSError` reads "no `.git`". + A FILE, A FOLDER THAT CANNOT BE LISTED OR ENTERED, OR A LINK TO NOTHING + IS `not-a-clone` TOO: nothing can be cloned into it. A holder whose + pinned `siblings.py` predates #735 ends `make siblings` in a traceback + on the first two, one that predates #784 ends `CLONE FAILED` on the + link, and one that has both calls each NOT A CLONE, so the row says + what is there and nothing of what `make siblings` does with it + (`beside_sentence`), which is true of every one of them. A folder that + cannot be entered cannot be asked for its `.git` either, so that + question's `OSError` reads "no `.git`". """ if sibling == root: return "is-the-holder", None @@ -8160,10 +8168,12 @@ def url_less_members(root: Path, rows: list) -> list[str]: } #: What stands at a `not-a-clone` slot that a holder's pinned `siblings.py` -#: from before #735 ends in a traceback on, as `unclonable` names it: the -#: row says what is there and no word of what `make siblings` does with it. +#: from before #735 ends in a traceback on, or from before #784 ends `CLONE +#: FAILED` on, as `unclonable` names it: the row says what is there and no +#: word of what `make siblings` does with it. UNREADABLE_BESIDE = {"file": "is a file, not a folder", - "unlistable": "is a folder that cannot be listed"} + "unlistable": "is a folder that cannot be listed", + "dangling": "is a link to nothing"} def beside_sentence(ctx: Context, entry: dict) -> str: @@ -8177,10 +8187,11 @@ def beside_sentence(ctx: Context, entry: dict) -> str: an ESC or a CR in one reached the terminal as the control it is. `--json` keeps them raw: `json.dumps` escapes them. - A FILE, OR A FOLDER THAT CANNOT BE LISTED, is `not-a-clone` with no - word from `make siblings`, which ends in a traceback on it in a holder - whose pinned copy predates #735: the sentence stops at what stands - there (`UNREADABLE_BESIDE`). + A FILE, A FOLDER THAT CANNOT BE LISTED, OR A LINK TO NOTHING is + `not-a-clone` with no word from `make siblings`, which ends in a + traceback on the first two in a holder whose pinned copy predates #735, + and `CLONE FAILED` on the link in one that predates #784: the sentence + stops at what stands there (`UNREADABLE_BESIDE`). """ state = entry["sibling"] where = ctx.root.parent / entry["project"] diff --git a/templates/family-root/scripts/siblings.py b/templates/family-root/scripts/siblings.py index c39f1d41..9465bd3f 100755 --- a/templates/family-root/scripts/siblings.py +++ b/templates/family-root/scripts/siblings.py @@ -483,9 +483,17 @@ def not_a_clone(target: Path) -> tuple[str, str] | None: the members after it were never placed. Nothing can tell that such a folder is empty, and `git clone` refuses one it cannot read. The words are the ones the doctor's `members` row says of the same two slots. + + A LINK TO NOTHING IS NOT A CLONE EITHER (#784). `exists` follows a + link, so a dangling one read as nothing at all: the dry run said `would + clone`, and the real run ended `CLONE FAILED`, because git cannot clone + into a path that is a link to nothing. Somebody made that link, so it + is reported and left as it is, in the doctor's words for it too. """ try: if not target.exists(): + if target.is_symlink(): + return "is a link to nothing", "link" return None if not target.is_dir(): return "is a file, not a folder", "file" diff --git a/tests/test_family.py b/tests/test_family.py index 8e0df3bf..9885f468 100644 --- a/tests/test_family.py +++ b/tests/test_family.py @@ -1916,37 +1916,65 @@ def test_siblings_reports_a_directory_that_is_not_a_clone_at_all(estate): reason="a mode hides a folder's contents only on POSIX, from a user " "who is not root") -#: `(the folder's mode, or None for a file; what the finding says is there; -#: what kind of thing it is)` for the slots `make siblings` ended in a -#: traceback on (#735): a file, and a folder holding a file whose mode hides -#: it, one that can be entered and not listed (`711` is common for another -#: user's directory) and one that cannot be entered. +#: A symbolic link needs a privilege on Windows. +LINKS_HOLD = pytest.mark.skipif( + os.name == "nt", reason="a symbolic link needs a privilege on Windows") + +#: The mode each locked folder is given: one that can be entered and not +#: listed (`711` is common for another user's directory), and one that +#: cannot be entered. +LOCKED_MODES = {"unlistable": 0o311, "unenterable": 0o000} + +#: `(what stands at the slot; what the finding says is there; what kind of +#: thing it is)` for the slots `make siblings` ended in a traceback on +#: before #735 (a file, and a folder holding a file whose mode hides it), +#: and the one it ended `CLONE FAILED` on before #784 (a link to nothing). SLOTS_NOT_READ = [ - pytest.param(None, "is a file, not a folder", "file", id="a-file"), - pytest.param(0o311, "is a folder that cannot be listed", "directory", - id="a-folder-it-cannot-list", marks=MODE_HOLDS), - pytest.param(0o000, "is a folder that cannot be listed", "directory", - id="a-folder-it-cannot-enter", marks=MODE_HOLDS), + pytest.param("file", "is a file, not a folder", "file", id="a-file"), + pytest.param("unlistable", "is a folder that cannot be listed", + "directory", id="a-folder-it-cannot-list", marks=MODE_HOLDS), + pytest.param("unenterable", "is a folder that cannot be listed", + "directory", id="a-folder-it-cannot-enter", + marks=MODE_HOLDS), + pytest.param("link", "is a link to nothing", "link", + id="a-link-to-nothing", marks=LINKS_HOLD), ] -def slot_not_read(request, slot, mode): - """A file at `slot`, or, for a `mode`, a folder there holding `notes.md` - with that mode, given its own mode back when the test ends; the file - that says `mine` is returned.""" - if mode is None: +def as_it_is(slot) -> tuple: + """What stands at `slot`: a link and where it leads, a folder's names + and what each file says, or a file and what it says.""" + if slot.is_symlink(): + return "link", os.readlink(slot) + if slot.is_dir(): + return "folder", {path.name: (path.read_text(encoding="utf-8") + if path.is_file() else None) + for path in slot.iterdir()} + return "file", slot.read_text(encoding="utf-8") + + +def slot_not_read(request, slot, kind) -> tuple: + """`kind` at `slot`: a file, a link to nothing, or a folder holding + `notes.md` with a `LOCKED_MODES` mode, given its own mode back when the + test ends. What stands there is returned, as `as_it_is` reads it before + the mode is taken.""" + if kind == "link": + slot.symlink_to(slot.parent / "nowhere", target_is_directory=True) + elif kind == "file": slot.write_text("mine\n", encoding="utf-8") - return slot - slot.mkdir() - (slot / "notes.md").write_text("mine\n", encoding="utf-8") - slot.chmod(mode) - request.addfinalizer(lambda: slot.chmod(0o755)) - return slot / "notes.md" + else: + slot.mkdir() + (slot / "notes.md").write_text("mine\n", encoding="utf-8") + before = as_it_is(slot) + if kind in LOCKED_MODES: + slot.chmod(LOCKED_MODES[kind]) + request.addfinalizer(lambda: slot.chmod(0o755)) + return before -@pytest.mark.parametrize("mode, found, thing", SLOTS_NOT_READ) +@pytest.mark.parametrize("kind, found, thing", SLOTS_NOT_READ) def test_siblings_reports_a_slot_it_cannot_read_as_not_a_clone( - estate, request, mode, found, thing): + estate, request, kind, found, thing): """#735: A FILE, OR A FOLDER IT CANNOT LIST, IS NOT A CLONE. `place` asked only whether a folder had something in it, and `iterdir` @@ -1955,9 +1983,13 @@ def test_siblings_reports_a_slot_it_cannot_read_as_not_a_clone( IRSS, the member after it, was never placed. Now each is `NOT A CLONE`, as the doctor's `members` row calls the slot: the finding is the one a folder of files gets, saying what stands there, the slot is left as it - is, and the next member is placed.""" + is, and the next member is placed. + + #784: AND SO IS A LINK TO NOTHING. `exists` follows a link, so it read + as nothing at all: the dry run said `would clone`, and git ended the + real run `CLONE FAILED`.""" slot = estate["folder"] / "IRRS" - mine = slot_not_read(request, slot, mode) + before = slot_not_read(request, slot, kind) result = siblings(estate["holder"]) assert "Traceback" not in result.stderr, result.stderr assert result.returncode == 1, result.stdout + result.stderr @@ -1969,21 +2001,25 @@ def test_siblings_reports_a_slot_it_cannot_read_as_not_a_clone( f"nothing here overwrites a {thing} somebody made.") in \ result.stderr, result.stderr assert (estate["folder"] / "IRSS" / ".git").is_dir() - if mode is not None: + if kind in LOCKED_MODES: slot.chmod(0o755) - assert mine.read_text(encoding="utf-8") == "mine\n" - assert not (slot / ".git").exists() + assert as_it_is(slot) == before -@MODE_HOLDS +@pytest.mark.parametrize("kind", [ + pytest.param("unenterable", id="a-folder-it-cannot-enter", + marks=MODE_HOLDS), + pytest.param("link", id="a-link-to-nothing", marks=LINKS_HOLD), +]) @pytest.mark.skipif(shutil.which("make") is None, reason="needs `make` on PATH") -def test_a_make_verb_skips_a_folder_it_cannot_enter(estate, request): +def test_a_make_verb_skips_a_slot_with_no_clone(estate, request, kind): """#735's `--make` half: `dispatch` asked `(path / ".git").exists()`, which Python 3.12 answers with `PermissionError` in a folder that cannot be entered, so `make park` ended in that traceback. It is now a member - with no working clone, skipped by name, as every other one is.""" - slot_not_read(request, estate["folder"] / "IRRS", 0o000) + with no working clone, skipped by name, as every other one is, and so + is a link to nothing (#784): `make park` and `make resume` skip it.""" + slot_not_read(request, estate["folder"] / "IRRS", kind) result = siblings(estate["holder"], "--make", "park", "--dry-run") assert "Traceback" not in result.stderr, result.stderr assert "sibling IRRS SKIPPED no clone -" in [ diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index fdf157fa..a6058a72 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -4003,8 +4003,9 @@ def test_the_stranger_note_says_from_which_folder_make_siblings_calls_it( } #: What the row says instead of a `not-a-clone` slot that a holder's pinned -#: `siblings.py` from before #735 ends in a traceback on: what is there, and -#: nothing of what `make siblings` does with it. +#: `siblings.py` from before #735 ends in a traceback on, or from before +#: #784 ends `CLONE FAILED` on: what is there, and nothing of what `make +#: siblings` does with it. UNREADABLE_SAYS = { "file": "is a file, not a folder, so it is not counted as its working " "clone", @@ -4012,6 +4013,8 @@ def test_the_stranger_note_says_from_which_folder_make_siblings_calls_it( "as its working clone", "unenterable": "is a folder that cannot be listed, so it is not counted " "as its working clone", + "dangling": "is a link to nothing, so it is not counted as its working " + "clone", } #: The state the holder's own `scripts/siblings.py --dry-run` prints in its @@ -4058,14 +4061,21 @@ def test_the_stranger_note_says_from_which_folder_make_siblings_calls_it( pytest.param("clone", "working-clone", id="the-working-clone"), ] -#: A file at `../IRRS`, and the two `LOCKED` folders: `not-a-clone`, and the -#: slots `make siblings` ended in a traceback on before #735. +#: A symbolic link needs a privilege on Windows. +LINKS_HOLD = pytest.mark.skipif( + os.name == "nt", reason="a symbolic link needs a privilege on Windows") + +#: A file at `../IRRS`, the two `LOCKED` folders and a link to nothing, each +#: `not-a-clone`: before #735 `make siblings` ended in a traceback on the +#: first three, and before #784 in `CLONE FAILED` on the link. UNREADABLE_SLOTS = [ pytest.param("file", "not-a-clone", id="a-file"), pytest.param("unlistable", "not-a-clone", id="a-folder-it-cannot-list", marks=LOCKED_HOLDS), pytest.param("unenterable", "not-a-clone", id="a-folder-it-cannot-enter", marks=LOCKED_HOLDS), + pytest.param("dangling", "not-a-clone", id="a-link-to-nothing", + marks=LINKS_HOLD), ] @@ -4090,6 +4100,8 @@ def holder_with_slot(holder: dict, tmp_path: Path, slot: str) -> Path: (beside / "notes.txt").write_text("somebody's\n", encoding="utf-8") elif slot == "file": beside.write_text("somebody's\n", encoding="utf-8") + elif slot == "dangling": + beside.symlink_to(folder / "nowhere", target_is_directory=True) elif slot == "another-repository": git("init", "-q", "-b", "main", ".", cwd=beside) elif slot not in ("holder", "nothing", "empty"): From 5cf26fcb6af3dbd21fddada8056bedaf683b4f6d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 09:48:25 +0000 Subject: [PATCH 3/4] siblings.py and shape-doctor.py: a Windows junction to nothing at `../` is NOT A CLONE, as a link to nothing is, so the dry run no longer says `would clone` where the real run ends CLONE FAILED, and the doctor's `members` row no longer calls the slot `absent` (#793) #784 told a link to nothing from an empty slot with `is_symlink`, which says no to a directory junction, the link Windows makes without privilege. `not_a_clone` in `siblings.py` and `unclonable` in the doctor now also ask `os.path.isjunction`, spelled as `family_link` spells it, `getattr(os.path, "isjunction", lambda _: False)`, since Python before 3.12 has none. Both still word it "is a link to nothing", and each script stays self-contained. The tests simulate the junction as the family-folder junction test does: the slot does not exist and only `os.path.isjunction` says yes. The placement test mounts the member from a local path that does not exist, so a run that took the slot for an empty one fails its clone offline. `siblings.py` is a family-root copy carried by digest: a holder pinned before this change shows an `upstream-changed` row for it. Co-Authored-By: Claude Opus 5.5 --- shape-doctor.py | 9 +++++-- templates/family-root/scripts/siblings.py | 8 +++++- tests/test_family_siblings_placement.py | 31 +++++++++++++++++++++++ tests/test_shape_doctor.py | 21 +++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/shape-doctor.py b/shape-doctor.py index fc59f79c..2f903951 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -7989,11 +7989,16 @@ def unclonable(sibling: Path) -> str | None: A LINK TO NOTHING IS NOT NOTHING (#784). `exists` follows a link, so a dangling one read as `absent`, and the row said `make siblings` places the member there, where git ends `CLONE FAILED`: it cannot clone into a - path that is a link to nothing. + path that is a link to nothing. A JUNCTION TO NOTHING is one too + (#793), the link Windows makes without privilege, which `is_symlink` + does not see: it is asked as `siblings.py` asks it, with Python 3.12's + `os.path.isjunction`, and before 3.12, which has none, the answer is no. """ try: if not sibling.exists(): - return "dangling" if sibling.is_symlink() else None + junction = getattr(os.path, "isjunction", lambda _: False) + return ("dangling" if sibling.is_symlink() or junction(sibling) + else None) if not sibling.is_dir(): return "file" return "files" if any(sibling.iterdir()) else None diff --git a/templates/family-root/scripts/siblings.py b/templates/family-root/scripts/siblings.py index 9465bd3f..78c3702b 100755 --- a/templates/family-root/scripts/siblings.py +++ b/templates/family-root/scripts/siblings.py @@ -489,10 +489,16 @@ def not_a_clone(target: Path) -> tuple[str, str] | None: clone`, and the real run ended `CLONE FAILED`, because git cannot clone into a path that is a link to nothing. Somebody made that link, so it is reported and left as it is, in the doctor's words for it too. + + AND SO IS A JUNCTION TO NOTHING (#793), the link Windows makes without + privilege: `is_symlink` says no to one, and Python 3.12's + `os.path.isjunction` says yes. Python before 3.12 has none, so it is + asked as `family_link` asks it, and says no there. """ try: if not target.exists(): - if target.is_symlink(): + junction = getattr(os.path, "isjunction", lambda _: False) + if target.is_symlink() or junction(target): return "is a link to nothing", "link" return None if not target.is_dir(): diff --git a/tests/test_family_siblings_placement.py b/tests/test_family_siblings_placement.py index c5d6e449..5442c23d 100644 --- a/tests/test_family_siblings_placement.py +++ b/tests/test_family_siblings_placement.py @@ -579,6 +579,37 @@ def test_a_family_folder_reached_through_a_windows_junction_passes( assert warning is None +@pytest.mark.parametrize("dry_run", [True, False], + ids=["the-dry-run", "the-real-run"]) +def test_a_windows_junction_to_nothing_at_a_slot_is_a_link_to_nothing( + placement, monkeypatch, capsys, tmp_path, dry_run): + """#793: a junction to nothing at `../` is NOT A CLONE, as a + link to nothing is (#784). `is_symlink` says no to a junction and + `os.path.isjunction` (3.12) says yes, so the slot read as empty: the + dry run said `would clone`. Simulated, as above, so every system asks + it: the slot does not exist, and only the junction check says yes. + + THE MEMBER IS MOUNTED FROM A LOCAL PATH THAT DOES NOT EXIST, so a run + that took the slot for an empty one fails its clone here and never + reaches a remote: with the `repository:` fallback, it cloned github.""" + root = tmp_path / NAME / NAME + root.mkdir(parents=True) + slot = root.parent / MEMBERS[0] + mount = f"members/{MEMBERS[0]}" + nowhere = {mount: str(tmp_path / "remotes" / f"{MEMBERS[0]}.git")} + monkeypatch.setattr(os.path, "isjunction", lambda path: Path(path) == slot, + raising=False) + found = placement.place(root, {"project": MEMBERS[0], "path": mount, + "repository": f"{NAME}/{MEMBERS[0]}"}, + nowhere, [], "", dry_run) + assert found.state == "NOT A CLONE", found.state + assert found.finding == ( + f"{MEMBERS[0]}: {slot} is a link to nothing, so this command will " + "not write into it. Move it aside or name the link something else; " + "nothing here overwrites a link somebody made."), found.finding + assert "would clone" not in capsys.readouterr().out + + def test_a_folder_is_renamed_only_for_a_difference_of_case( placement, monkeypatch, tmp_path): """A second path to this folder under another NAME that is neither a diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index a6058a72..0bea0fff 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -4187,6 +4187,27 @@ def test_make_siblings_dry_run_gives_the_rows_word_for_each_slot( f"sibling {FAMILY_MEMBER} {DRY_RUN_SAYS[entry['sibling']]} "), said +def test_the_row_names_a_windows_junction_to_nothing_a_link_to_nothing( + standard, holder, tmp_path, monkeypatch): + """#793: a junction to nothing at `../IRRS`, the link Windows makes + without privilege, is `not-a-clone` in the doctor's row, in the words + `make siblings` says it in, as a link to nothing is (#784). `is_symlink` + says no to a junction, so the row called the slot `absent` and said + `make siblings` places the member there. Simulated in this process, as + `tests/test_family_siblings_placement.py` simulates a junction: the slot + does not exist, and only `os.path.isjunction` says yes.""" + root = holder_with_slot(holder, tmp_path, "nothing").resolve() + slot = root.parent / FAMILY_MEMBER + module = doctor_module(standard) + monkeypatch.setattr(os.path, "isjunction", lambda path: Path(path) == slot, + raising=False) + row = module.check_members(module.Context(root)) + assert row.detail["members"][0]["sibling"] == "not-a-clone", row.detail + assert f"{FAMILY_MEMBER}: {slot} {UNREADABLE_SAYS['dangling']}" in \ + row.reason, row.reason + assert PLACES_NOTE not in row.reason, row.reason + + def test_a_sibling_at_the_right_origin_with_another_id_is_not_the_member( standard, holder, tmp_path): """The `WRONG PROJECT` half, which was already asked and stays asked: a From 2ffe154ca029764875b48f72c30ed132ab9c07c4 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 10:04:37 +0000 Subject: [PATCH 4/4] test_family_siblings_placement.py and test_shape_doctor.py: a Windows junction to a folder at `../` is read as that folder, so a junction check moved above `exists()` fails a test (#793) Review round 2 (NIT N2 from RV-d on #794): no test pinned the junction check after `exists()`, and both mutants that ask it first survived. A junction to a live folder is now asked about too, with `os.path.isjunction` saying yes for a slot that exists: - an empty folder: the dry run says `would clone`, and the doctor's row says `absent`, with the note that `make siblings` places the member; - a folder holding files: `place` gives the finding a folder of files gets ("exists, is not empty and is not a git clone"), and the row says `not-a-clone` in a folder's words. The setup the round-1 tests had is shared through `placed_at_a_junction` and `refused` in the placement tests and `junction_row` in the doctor's, and those tests assert what they did. Co-Authored-By: Claude Opus 5.5 --- tests/test_family_siblings_placement.py | 72 +++++++++++++++++++------ tests/test_shape_doctor.py | 45 ++++++++++++---- 2 files changed, 92 insertions(+), 25 deletions(-) diff --git a/tests/test_family_siblings_placement.py b/tests/test_family_siblings_placement.py index 5442c23d..f8558d1a 100644 --- a/tests/test_family_siblings_placement.py +++ b/tests/test_family_siblings_placement.py @@ -579,15 +579,13 @@ def test_a_family_folder_reached_through_a_windows_junction_passes( assert warning is None -@pytest.mark.parametrize("dry_run", [True, False], - ids=["the-dry-run", "the-real-run"]) -def test_a_windows_junction_to_nothing_at_a_slot_is_a_link_to_nothing( - placement, monkeypatch, capsys, tmp_path, dry_run): - """#793: a junction to nothing at `../` is NOT A CLONE, as a - link to nothing is (#784). `is_symlink` says no to a junction and - `os.path.isjunction` (3.12) says yes, so the slot read as empty: the - dry run said `would clone`. Simulated, as above, so every system asks - it: the slot does not exist, and only the junction check says yes. +def placed_at_a_junction(placement, monkeypatch, tmp_path, holds, + dry_run): + """The first member's slot, `../`, and what `place` makes of + it, with `os.path.isjunction` saying yes for that slot. Simulated, as + above, so every system asks it. With `holds` None the slot does not + exist, and only the junction check says yes; otherwise it is a folder + holding the files `holds` names. THE MEMBER IS MOUNTED FROM A LOCAL PATH THAT DOES NOT EXIST, so a run that took the slot for an empty one fails its clone here and never @@ -595,21 +593,63 @@ def test_a_windows_junction_to_nothing_at_a_slot_is_a_link_to_nothing( root = tmp_path / NAME / NAME root.mkdir(parents=True) slot = root.parent / MEMBERS[0] + if holds is not None: + slot.mkdir() + for name in holds: + (slot / name).write_text("mine\n", encoding="utf-8") mount = f"members/{MEMBERS[0]}" nowhere = {mount: str(tmp_path / "remotes" / f"{MEMBERS[0]}.git")} monkeypatch.setattr(os.path, "isjunction", lambda path: Path(path) == slot, raising=False) - found = placement.place(root, {"project": MEMBERS[0], "path": mount, - "repository": f"{NAME}/{MEMBERS[0]}"}, - nowhere, [], "", dry_run) + return slot, placement.place( + root, {"project": MEMBERS[0], "path": mount, + "repository": f"{NAME}/{MEMBERS[0]}"}, + nowhere, [], "", dry_run) + + +def refused(slot: Path, found: str, thing: str) -> str: + """`place`'s NOT A CLONE finding for the first member at `slot`.""" + return (f"{MEMBERS[0]}: {slot} {found}, so this command will not write " + f"into it. Move it aside or name the {thing} something else; " + f"nothing here overwrites a {thing} somebody made.") + + +@pytest.mark.parametrize("dry_run", [True, False], + ids=["the-dry-run", "the-real-run"]) +def test_a_windows_junction_to_nothing_at_a_slot_is_a_link_to_nothing( + placement, monkeypatch, capsys, tmp_path, dry_run): + """#793: a junction to nothing at `../` is NOT A CLONE, as a + link to nothing is (#784). `is_symlink` says no to a junction and + `os.path.isjunction` (3.12) says yes, so the slot read as empty: the + dry run said `would clone`.""" + slot, found = placed_at_a_junction(placement, monkeypatch, tmp_path, + None, dry_run) assert found.state == "NOT A CLONE", found.state - assert found.finding == ( - f"{MEMBERS[0]}: {slot} is a link to nothing, so this command will " - "not write into it. Move it aside or name the link something else; " - "nothing here overwrites a link somebody made."), found.finding + assert found.finding == refused(slot, "is a link to nothing", "link"), \ + found.finding assert "would clone" not in capsys.readouterr().out +@pytest.mark.parametrize("holds, state, finding", [ + pytest.param([], "would clone", None, id="an-empty-folder"), + pytest.param(["notes.md"], "NOT A CLONE", + ("exists, is not empty and is not a git clone", "directory"), + id="a-folder-with-files"), +]) +def test_a_windows_junction_to_a_folder_at_a_slot_is_read_as_that_folder( + placement, monkeypatch, tmp_path, holds, state, finding): + """#793: THE JUNCTION IS ASKED ONLY OF A SLOT THAT DOES NOT EXIST. A + junction to a folder is read as that folder is, as a link to one is: + the dry run would clone into an empty one, and gives one holding files + the finding a folder of files gets. Asked before `exists`, the check + called both a link to nothing.""" + slot, found = placed_at_a_junction(placement, monkeypatch, tmp_path, + holds, True) + assert found.state == state, found.state + expected = refused(slot, *finding) if finding else None + assert found.finding == expected, found.finding + + def test_a_folder_is_renamed_only_for_a_difference_of_case( placement, monkeypatch, tmp_path): """A second path to this folder under another NAME that is neither a diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index 0bea0fff..4fc3ae63 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -4187,27 +4187,54 @@ def test_make_siblings_dry_run_gives_the_rows_word_for_each_slot( f"sibling {FAMILY_MEMBER} {DRY_RUN_SAYS[entry['sibling']]} "), said +def junction_row(standard, holder, tmp_path, monkeypatch, slot: str): + """`../IRRS`, made as `holder_with_slot` makes `slot`, and the doctor's + `members` row with `os.path.isjunction` saying yes for it. Simulated in + this process, as `tests/test_family_siblings_placement.py` simulates a + junction.""" + root = holder_with_slot(holder, tmp_path, slot).resolve() + beside = root.parent / FAMILY_MEMBER + module = doctor_module(standard) + monkeypatch.setattr(os.path, "isjunction", + lambda path: Path(path) == beside, raising=False) + return beside, module.check_members(module.Context(root)) + + def test_the_row_names_a_windows_junction_to_nothing_a_link_to_nothing( standard, holder, tmp_path, monkeypatch): """#793: a junction to nothing at `../IRRS`, the link Windows makes without privilege, is `not-a-clone` in the doctor's row, in the words `make siblings` says it in, as a link to nothing is (#784). `is_symlink` says no to a junction, so the row called the slot `absent` and said - `make siblings` places the member there. Simulated in this process, as - `tests/test_family_siblings_placement.py` simulates a junction: the slot - does not exist, and only `os.path.isjunction` says yes.""" - root = holder_with_slot(holder, tmp_path, "nothing").resolve() - slot = root.parent / FAMILY_MEMBER - module = doctor_module(standard) - monkeypatch.setattr(os.path, "isjunction", lambda path: Path(path) == slot, - raising=False) - row = module.check_members(module.Context(root)) + `make siblings` places the member there. The slot does not exist, and + only `os.path.isjunction` says yes.""" + slot, row = junction_row(standard, holder, tmp_path, monkeypatch, + "nothing") assert row.detail["members"][0]["sibling"] == "not-a-clone", row.detail assert f"{FAMILY_MEMBER}: {slot} {UNREADABLE_SAYS['dangling']}" in \ row.reason, row.reason assert PLACES_NOTE not in row.reason, row.reason +@pytest.mark.parametrize("slot, state", [ + pytest.param("empty", "absent", id="an-empty-folder"), + pytest.param("files", "not-a-clone", id="a-folder-with-files"), +]) +def test_the_row_reads_a_windows_junction_to_a_folder_as_that_folder( + standard, holder, tmp_path, monkeypatch, slot, state): + """#793: THE JUNCTION IS ASKED ONLY OF A SLOT THAT DOES NOT EXIST. A + junction to a folder is read as that folder is, in the words the row + has for the folder itself + (`test_the_row_says_what_make_siblings_does_with_each_slot`): an empty + one is `absent`, which `make siblings` places the member in, and one + holding files is `not-a-clone` in a folder's words. Asked before + `exists`, the check called both a link to nothing.""" + _, row = junction_row(standard, holder, tmp_path, monkeypatch, slot) + assert row.detail["members"][0]["sibling"] == state, row.detail + assert SLOT_SAYS[state] in row.reason, row.reason + assert (PLACES_NOTE in row.reason) is (state == "absent"), row.reason + + def test_a_sibling_at_the_right_origin_with_another_id_is_not_the_member( standard, holder, tmp_path): """The `WRONG PROJECT` half, which was already asked and stays asked: a