diff --git a/shape-doctor.py b/shape-doctor.py index b2a78b3b..c7751d36 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -9444,11 +9444,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 39707da5..f7115eca 100755 --- a/templates/family-root/scripts/siblings.py +++ b/templates/family-root/scripts/siblings.py @@ -605,10 +605,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 a34f98d5..2a020ea5 100644 --- a/tests/test_family_siblings_placement.py +++ b/tests/test_family_siblings_placement.py @@ -579,6 +579,77 @@ def test_a_family_folder_reached_through_a_windows_junction_passes( assert warning is None +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 + reaches a remote: with the `repository:` fallback, it cloned github.""" + 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) + 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 == 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 ff877330..965e53f7 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -4187,6 +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. 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