Skip to content
45 changes: 28 additions & 17 deletions shape-doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8490,8 +8490,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
Expand All @@ -8500,10 +8501,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
Expand All @@ -8524,13 +8530,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
Expand Down Expand Up @@ -8676,10 +8684,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:
Expand All @@ -8693,10 +8703,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"]
Expand Down
8 changes: 8 additions & 0 deletions templates/family-root/scripts/siblings.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,9 +530,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"
Expand Down
100 changes: 68 additions & 32 deletions tests/test_family.py
Original file line number Diff line number Diff line change
Expand Up @@ -1934,37 +1934,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`
Expand All @@ -1973,9 +2001,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
Expand All @@ -1987,21 +2019,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 [
Expand Down
20 changes: 16 additions & 4 deletions tests/test_shape_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4003,15 +4003,18 @@ 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",
"unlistable": "is a folder that cannot be listed, so it is not counted "
"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
Expand Down Expand Up @@ -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),
]


Expand All @@ -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"):
Expand Down
Loading