From 1d1499337f2aad2d71188bb9f3fd833cb3463b31 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 00:53:17 +0000 Subject: [PATCH 1/2] 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/2] 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"):