diff --git a/shape-doctor.py b/shape-doctor.py index b227cbd8..86e1442b 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -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 @@ -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 @@ -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 @@ -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: @@ -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"] diff --git a/templates/family-root/scripts/siblings.py b/templates/family-root/scripts/siblings.py index e78e2edb..30f060e9 100755 --- a/templates/family-root/scripts/siblings.py +++ b/templates/family-root/scripts/siblings.py @@ -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" diff --git a/tests/test_family.py b/tests/test_family.py index e94494ed..ac12a5bf 100644 --- a/tests/test_family.py +++ b/tests/test_family.py @@ -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` @@ -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 @@ -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 [ diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index 10722cfd..65ebe051 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"):