Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions shape-doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion templates/family-root/scripts/siblings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
71 changes: 71 additions & 0 deletions tests/test_family_siblings_placement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, `../<Project>`, 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 `../<Project>` 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
Expand Down
48 changes: 48 additions & 0 deletions tests/test_shape_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading