diff --git a/adopt-project.py b/adopt-project.py index a8b1ef34..d4ab3bf2 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -5179,16 +5179,17 @@ def _checked_flag_inputs(args, naming: NamingPolicy) -> None: source. NOT EVERY REFUSAL A FLAG DECIDES IS HERE. Still after the source, for - an `org/repo` source as for a local one: `naming-unclassified` and - `naming-role-mismatch`, which `_check_names` asks of the names - `--project` makes, with a remedy about the source; an empty - `--elected-by`, which is a value `unwritable_values` holds and the - manifest validator refuses (`adopt-manifest-invalid`, in - `_refuse_unholdable_values`), and an empty `--reference` with it - (#902); and a missing `--path-policy` (`yaml-unreadable`). A passed - `--id` that is no id is here only where `naming` holds the names - `--project` makes (`_names_held`, #902): beside a name it refuses, the - naming refusal speaks first, and its remedy needs the source. + an `org/repo` source as for a local one, among them: + `naming-unclassified` and `naming-role-mismatch`, which `_check_names` + asks of the names `--project` makes, with a remedy about the source + where it names a repository; an empty `--elected-by`, which is a value + `unwritable_values` holds and the manifest validator refuses + (`adopt-manifest-invalid`, in `_refuse_unholdable_values`), and an + empty `--reference` with it (#902); and a missing `--path-policy` + (`yaml-unreadable`). A passed `--id` that is no id is here only where + `naming` holds the names `--project` makes (`_names_held`, #902): + beside a name it refuses, the naming refusal speaks first, and its + remedy needs the source. """ args.project = checked_value("--project", args.project) # A `--tracking-branch` that was passed: PASSED IS ON THE COMMAND LINE, @@ -5196,9 +5197,9 @@ def _checked_flag_inputs(args, naming: NamingPolicy) -> None: # `checked_value` (#420): a name inside its alphabet that git will not # create (`a..b`, `~`, `x.lock`, `a/`) is refused here, in git's words, # and is not written for `check` to find it no branch of the source and - # the plan to be written again. That question is asked of a throwaway - # repository in the system temp folder, which it removes, and never of - # the source. + # the plan to be written again. That question is asked with an empty + # temporary directory as its `--git-dir`, which `plan` removes, and + # never of the source. if args.tracking_branch is not None: tracking = _flag_named(TRACKING_KEY, {TRACKING_KEY}) args.tracking_branch = checked_value(tracking, args.tracking_branch) @@ -6026,8 +6027,9 @@ def cmd_plan(args) -> int: # made, git had read the source and an `org/repo` one was cloned, and # with a clone that failed they were never reached: no work directory # is made, git reads nothing of the source and no `gh` runs before - # them. A passed `--tracking-branch` runs `git check-ref-format` in a - # throwaway repository first, and never touches the source. + # them. A passed `--tracking-branch` runs `git check-ref-format` first, + # with an empty temporary directory as its `--git-dir`, which `plan` + # removes, and never touches the source. # The naming policy is read first, a file of this checkout: a passed # `--id` is refused with the flags only where the names `--project` # makes are ones it holds (#902). @@ -12280,13 +12282,14 @@ def _refuse_an_occupied_work_dir(work_root: Path, names: dict, NO_LEG_YET = "no leg repository was created and nothing was pushed. " -def _work_dir_refusal(detail: str, done: str = "") -> Refusal: +def _work_dir_refusal(detail: str, done: str = "", + fix: str = WORK_DIR_FIX) -> Refusal: """`adopt-work-dir-unwritable` for `detail`, why the `--work-dir` cannot be used: `_work_root`'s or `_unwritable_work_dir`'s. The one refusal of a work directory, in all three commands (#650); `done` is - what the refusing command left as it was, said first.""" - return Refusal(WORK_DIR_UNWRITABLE, detail, - f"Remediation: {done}{WORK_DIR_FIX}") + what the refusing command left as it was, said first, and `fix` the way + out, which is #524's but for a symlink in the way (#978).""" + return Refusal(WORK_DIR_UNWRITABLE, detail, f"Remediation: {done}{fix}") def _unwritable_work_dir(args, work_root: Path, @@ -16046,8 +16049,11 @@ def _work_root(args, done: str = "") -> Path: FileExistsError, NotADirectoryError or PermissionError traceback, exit 1, and on a loop Python 3.12's `Path.resolve` raised RuntimeError first; `os.path.realpath` is the same reading without that raise. The - code and the way out are #524's, for one this user cannot write in; - `done` is what the refusing command left as it was, said first. + code and the way out are #524's, for one this user cannot write in, but + a symlink in the way is named, with a way out of its own: one at the + `--work-dir` or on the way to it that leads round a loop, or one at it + that leads to what is no directory (`_unmade_work_dir`, #978); `done` + is what the refusing command left as it was, said first. A `--work-dir` ANOTHER USER CAN RENAME IN IS REFUSED NEXT, before anything is made in it (#803): one another user owns, or one every user @@ -16071,9 +16077,8 @@ def _work_root(args, done: str = "") -> Path: _made_0755(root) status = os.lstat(root) except OSError as exc: - raise _work_dir_refusal( - f"--work-dir {_spelled(str(root))} cannot be made: making it a " - f"directory failed ({exc.strerror or exc})", done) from None + detail, fix = _unmade_work_dir(args.work_dir, root, exc) + raise _work_dir_refusal(detail, done, fix) from None _refuse_a_shared_chain(root, done) shared = _shared_with_another_user(status) if shared: @@ -16084,6 +16089,89 @@ def _work_root(args, done: str = "") -> Path: return root +#: Why a `--work-dir` that is a symlink round a loop cannot be made (#978). +SYMLINK_LOOP = "it is a symbolic link that leads round a loop" + + +def _unmade_work_dir(work_dir: Path, root: Path, + exc: OSError) -> tuple[str, str]: + """Why the `--work-dir` `work_dir`, which `_work_root` read as `root` + (`os.path.realpath`), could not be made, where making it failed with + `exc`, and the way out: `_work_root`'s refusal (#650). + + A SYMLINK IN THE WAY IS NAMED AS ONE (#978). `os.mkdir` of a symlink + that leads round a loop fails with EEXIST, the error a file in the way + gets, so the reason was "(File exists)": the person was told a file is + there, and found a link, and the way out was for a folder this user + cannot write in. Where the `--work-dir` is a symlink, or the way to it + runs through one, that leads round a loop, the reason says so and names + the link, and the link in the loop where that is another; where it is a + symlink that leads to a file, or to anything else that is no directory, + the reason names the link and what it leads to. The way out is to + remove the link or make it lead to a directory: the `--work-dir` itself + where it is a symlink, else the link in the loop. The `--work-dir` is + named as given, made absolute and not resolved, since + `os.path.realpath` stops at a loop on a path the person may never have + typed. + + A DANGLING SYMLINK IS NO LOOP: `os.path.realpath` reads it as the path + it leads to, which `_made_0755` makes, and only where that fails does + it get here. That failure, and every other, a file at the `--work-dir` + or above it among them, is said in the system's own words, of `root`, + as before.""" + given = work_dir.absolute() + link = given if os.path.islink(given) else None + looped = (_the_loop_in(root) + if exc.errno in (errno.EEXIST, errno.ELOOP) else None) + if looped is not None: + if link is None: + why = ("the way to it leads round a loop, at the symbolic link " + f"{_spelled(str(looped))}") + link = looped + elif _same_entry(link, looped): + why = SYMLINK_LOOP + else: + why = (f"{SYMLINK_LOOP}, at the symbolic link " + f"{_spelled(str(looped))}") + elif link is not None and exc.errno == errno.EEXIST: + why = (f"it is a symbolic link that leads to {_spelled(str(root))}, " + "which is not a directory") + else: + return (f"--work-dir {_spelled(str(root))} cannot be made: making it " + f"a directory failed ({exc.strerror or exc})", WORK_DIR_FIX) + return (f"--work-dir {_spelled(str(given))} cannot be made: {why}", + f"Remove the symbolic link {_spelled(str(link))}, or make it " + "lead to a directory, or pass another --work-dir, or none: " + "without one, each run makes a new one.") + + +def _the_loop_in(root: Path) -> Path | None: + """The symlink in `root`, a `--work-dir` as `os.path.realpath` read it, + that leads round a loop; None where there is none (#978). + `os.path.realpath` follows every symlink but one it meets again before + it has resolved it, which it leaves in the path as it found it, with + the rest of the path unread after it. So the first symlink in `root`, + from `/` down, is that one, and the system is asked whether following + it loops (ELOOP), so that nothing else is called a loop.""" + for part in (*reversed(root.parents), root): + if os.path.islink(part): + try: + os.stat(part) + except OSError as exc: + return part if exc.errno == errno.ELOOP else None + return None + return None + + +def _same_entry(one: Path, other: Path) -> bool: + """Whether `one` and `other` are the same directory entry, a symlink + taken as itself and not followed.""" + try: + return os.path.samestat(os.lstat(one), os.lstat(other)) + except OSError: + return False + + def _holds_a_file(root: Path) -> bool: """Whether anything but directories is in `root`, at any depth: a file, a symlink, anything else that is not a directory. A directory that diff --git a/tests/test_adopt_clone_claimed.py b/tests/test_adopt_clone_claimed.py index 697a7988..0956db41 100644 --- a/tests/test_adopt_clone_claimed.py +++ b/tests/test_adopt_clone_claimed.py @@ -39,7 +39,8 @@ import pytest from conftest import ADOPT, WINDOWS_SKIP, make_source_repo, rmtree, spelled -from test_adopt_work_dir_refusal import refused_alike +from test_adopt_work_dir_refusal import (refused_alike, refused_for_a_link, + through_a_loop) from test_adopt_source_clone_record import ( # noqa: F401 MINE, OCCUPIED, SOURCE_READONLY, WORK, WORK_FLAG, clone_dir, modes_hold, on_github, plan_args, run, symlinks_are_made, @@ -731,9 +732,11 @@ def test_a_loop_or_a_file_above_the_work_dir_keeps_its_650_refusal( tmp_path, command, above): """`--work-dir /loop/W` (`loop -> loop`) and `/notes.txt/W`, a file every user can write: no directory can be made there, and each - command refuses it as #650 does, in main's words. On `bba7e445` each was - `adopt-work-dir-shared`, naming the link or the file as a directory - every user can write in: a symlink always reads as mode 0777.""" + command refuses it as #650 does, `adopt-work-dir-unwritable`: the file + in the system's words, and the loop in #978's, which name the link. On + `bba7e445` each was `adopt-work-dir-shared`, naming the link or the + file as a directory every user can write in: a symlink always reads as + mode 0777.""" planned_locally(tmp_path, {}, {}, {}) if above == "a-symlink-loop": in_the_way = tmp_path / "loop" @@ -741,10 +744,13 @@ def test_a_loop_or_a_file_above_the_work_dir_keeps_its_650_refusal( in_the_way.symlink_to(in_the_way.name) except OSError: pytest.skip("this machine cannot make a symlink") - else: - in_the_way = tmp_path / "notes.txt" - in_the_way.write_text(MINE, encoding="utf-8") - os.chmod(in_the_way, 0o666) + loop = real(tmp_path) / in_the_way.name + refused_for_a_link(command, tmp_path, in_the_way / WORK, + through_a_loop(loop), loop) + return + in_the_way = tmp_path / "notes.txt" + in_the_way.write_text(MINE, encoding="utf-8") + os.chmod(in_the_way, 0o666) refused_alike(command, tmp_path, in_the_way / WORK) diff --git a/tests/test_adopt_plan_flag_refusals_first.py b/tests/test_adopt_plan_flag_refusals_first.py index a03eaed7..ad25a82d 100644 --- a/tests/test_adopt_plan_flag_refusals_first.py +++ b/tests/test_adopt_plan_flag_refusals_first.py @@ -26,18 +26,19 @@ Now the refusals of a passed flag's own value are asked first, as `--pin-source` and `--out` are: no work directory, no `git` on the source and -no `gh` before them (a passed `--tracking-branch` is asked of git alone, in a -throwaway repository). An `org/repo` source is held to its derived id, `--org` -and `--project` before the clone, and its unwritable folder is refused in -words about the clone. A local source's folder is still made, since `--out` -may lie in it (`tests/test_adopt_plan_out_checked.py`), and still refused -where it cannot be made (`tests/test_adopt_work_dir_refusal.py`); it is no -longer probed. +no `gh` before them (a passed `--tracking-branch` is asked of git alone, with +an empty temporary directory as its `--git-dir`, which `plan` removes). An +`org/repo` source is held to its derived id, `--org` and `--project` before +the clone, and its unwritable folder is refused in words about the clone. A +local source's folder is still made, since `--out` may lie in it +(`tests/test_adopt_plan_out_checked.py`), and still refused where it cannot +be made (`tests/test_adopt_work_dir_refusal.py`); it is no longer probed. Not every refusal a flag decides is asked first. Still after the source is -read, for an `org/repo` source as for a local one: `naming-unclassified` and -`naming-role-mismatch`, an empty `--elected-by` (the manifest validator's -`adopt-manifest-invalid`) and a missing `--path-policy` (`yaml-unreadable`). +read, for an `org/repo` source as for a local one, among them: +`naming-unclassified` and `naming-role-mismatch`, an empty `--elected-by` +(the manifest validator's `adopt-manifest-invalid`) and a missing +`--path-policy` (`yaml-unreadable`). The runs here are given a fresh `TMPDIR`, a `GIT_TRACE` file that any `git` process would write, a `--work-dir` that is not there, and a `gh` that logs @@ -240,9 +241,10 @@ def test_a_refusal_the_flags_alone_decide_is_asked_before_anything_exists( def test_the_branch_a_tracking_branch_names_is_asked_before_the_source( harness, source): - """`--tracking-branch a..b` is git's to refuse, and is asked of a - throwaway repository in the temp folder, never of the source: the only - `git` that ran is `check-ref-format`, and it removed what it made.""" + """`--tracking-branch a..b` is git's to refuse, and is asked of git + with an empty temporary directory as its `--git-dir`, never of the + source: the only `git` that ran is `check-ref-format`, and the + directory was removed.""" for given in (REPOSITORY, str(source)): result = harness.plan(given, "--tracking-branch", "a..b", work=True) assert result.returncode == 2, result.stdout + result.stderr diff --git a/tests/test_adopt_work_dir_refusal.py b/tests/test_adopt_work_dir_refusal.py index 5d2bdb44..33fec4c4 100644 --- a/tests/test_adopt_work_dir_refusal.py +++ b/tests/test_adopt_work_dir_refusal.py @@ -28,6 +28,17 @@ a repository, empty or under a `~user` this machine has not, before it makes the `--work-dir`: on main it made it first. +And (#978) a `--work-dir` that is a symlink round a loop is refused in +words that name the link and the loop. On main at 1a47ed07 a link to +itself, or one of a pair that lead to each other, was refused as "making +it a directory failed (File exists)", which reads as a file in the way, +with the way out for a folder this user cannot write in; a link that leads +into a loop it is not part of, or one whose path runs through a loop, said +`File exists` or `Too many levels of symbolic links` of a path the person +never typed, and a link to a file named the file and not the link. Each +names the link now, and the way out is to remove it or make it lead to a +directory. A dangling link is still made where it leads. + `chmod` keeps neither root nor a Windows user from making a directory, so the tests of such a directory skip where it cannot (`unwritable_work`). Nothing here reaches `gh`: every refusal comes before any clone. @@ -40,8 +51,8 @@ import pytest -from conftest import (ADOPT, git, make_source_repo, rmtree, run_script, - write_plan) +from conftest import (ADOPT, git, load_without_bytecode, make_source_repo, + rmtree, run_script, write_plan) from test_adopt_plan_leg_path_help import HELP_ENV, option_help, option_lines from test_adopt_root_symlinks import ( # noqa: F401 BASIC, PLAN_FILE, PROJECT, REMOTES, THING, WORK_FLAG, check, @@ -90,11 +101,70 @@ def run_in(command: str, base: Path, work: Path, *source: str): WORK_FLAG, str(work)) -def refused(command: str, detail: str) -> str: - """The whole of stderr when `command` refuses a `--work-dir`.""" +def refused(command: str, detail: str, fix: str = FIX) -> str: + """The whole of stderr when `command` refuses a `--work-dir`, with the + way out `fix`.""" done = NOTHING_PUSHED if command == EXECUTE else "" return (f"REFUSED {WORK_DIR_UNWRITABLE}: {detail}\n" - f"Remediation: {done}{FIX}\n") + f"Remediation: {done}{fix}\n") + + +#: What a `--work-dir` that is a symlink round a loop is refused for, in +#: #978's words. +LOOPS = "it is a symbolic link that leads round a loop" + + +def link_fix(link: Path) -> str: + """The way out of a `--work-dir` that cannot be made because of the + symlink `link`, the `--work-dir` or one on the way to it (#978).""" + return (f"Remove the symbolic link {ticked(str(link))}, or make it lead " + "to a directory, or pass another --work-dir, or none: without " + "one, each run makes a new one.") + + +def linked(link: Path, target: str) -> None: + """`link`, a symlink to `target`; the test skips on a machine that + cannot make one.""" + try: + link.symlink_to(target) + except OSError: + pytest.skip("this machine cannot make a symlink") + + +def listed(base: Path) -> list[str]: + """Every entry under `base`, a symlink as itself and never followed.""" + return sorted(os.path.relpath(os.path.join(top, name), base) + for top, directories, files in os.walk(base) + for name in (*directories, *files)) + + +def refused_for_a_link(command: str, base: Path, work: Path, why: str, + link: Path) -> None: + """`command --work-dir work` refuses it by name, exit 2, as `why`, with + the way out for `link`: before any leg exists, with no plan written, + and with nothing made or changed under `base` (#978).""" + before = listed(base) + result = run_in(command, base, work) + assert result.returncode == 2, result.stderr + result.stdout + assert result.stderr == refused( + command, f"--work-dir {ticked(str(work))} cannot be made: {why}", + link_fix(link)) + assert listed(base) == before + assert not (base / AGAIN).exists() + no_leg_was_created(base) + + +def real(base: Path, name: str) -> Path: + """`name` in `base`, the directory read as `os.path.realpath` reads it: + the spelling `_work_root` finds a link in.""" + return Path(os.path.realpath(base)) / name + + +def through_a_loop(loop: Path) -> str: + """Why a `--work-dir` that is no link, under `loop`, a link to itself, + cannot be made (#978).""" + return ("the way to it leads round a loop, at the symbolic link " + f"{ticked(str(loop))}") def plan_cannot_write(work: Path, repository: str) -> str: @@ -153,18 +223,132 @@ def test_a_work_dir_that_a_file_is_in_the_way_of_is_refused( @pytest.mark.parametrize("command", COMMANDS) -def test_a_work_dir_that_is_a_symlink_loop_is_refused(tmp_path, command): - """A symlink at the `--work-dir` that leads to itself: no directory can - be made there, and each command refuses it by name, where Python 3.12's - `Path.resolve` ended it in a `RuntimeError` traceback before `mkdir`.""" +@pytest.mark.parametrize("pair", [pytest.param(False, id="to-itself"), + pytest.param(True, id="a-pair")]) +def test_a_work_dir_that_is_a_symlink_loop_is_refused(tmp_path, command, + pair): + """A symlink at the `--work-dir` that leads to itself, or to another + that leads back to it: no directory can be made there, and each command + refuses it by name, where Python 3.12's `Path.resolve` ended it in a + `RuntimeError` traceback before `mkdir`. The reason names the link and + the loop, and the way out the link (#978): on main at 1a47ed07 both + were refused as "making it a directory failed (File exists)", the words + of a file in the way, with the way out for a folder this user cannot + write in.""" planned(tmp_path, {}, {}, {}) loop = tmp_path / "loop" - try: - loop.symlink_to(loop.name) - except OSError: - pytest.skip("this machine cannot make a symlink") - refused_alike(command, tmp_path, loop) - assert loop.is_symlink() + linked(loop, "other" if pair else loop.name) + if pair: + linked(tmp_path / "other", loop.name) + refused_for_a_link(command, tmp_path, loop, LOOPS, loop) + assert os.readlink(loop) == ("other" if pair else loop.name) + + +#: `--work-dir work`, a symlink into a loop it is not part of: the links, +#: as `(name, target)`, and the one in the loop `os.path.realpath` stops at. +#: It leads to one of a pair of links that lead to each other, or to a path +#: under a link to itself, which fails `mkdir` with ELOOP and not EEXIST. +INTO_A_LOOP = { + "into-a-pair": ((("work", "a"), ("a", "b"), ("b", "a")), "a"), + "through-a-loop": ((("work", "loop/sub"), ("loop", "loop")), "loop"), +} + + +@pytest.mark.parametrize("command", COMMANDS) +@pytest.mark.parametrize("shape", list(INTO_A_LOOP)) +def test_a_work_dir_that_leads_into_a_loop_is_refused(tmp_path, command, + shape): + """The `--work-dir` is a symlink that leads round a loop further on. + The reason names it, and the link in the loop where `os.path.realpath` + stopped; the way out names the `--work-dir`, which can be removed or + mended whatever the loop is. On main at 1a47ed07 the reason named only + the link in the loop, as if it were the `--work-dir`, with `File + exists` or `Too many levels of symbolic links`.""" + planned(tmp_path, {}, {}, {}) + links, stop = INTO_A_LOOP[shape] + for name, target in links: + linked(tmp_path / name, target) + work = tmp_path / "work" + refused_for_a_link( + command, tmp_path, work, + f"{LOOPS}, at the symbolic link {ticked(str(real(tmp_path, stop)))}", + work) + + +@pytest.mark.parametrize("command", COMMANDS) +def test_a_work_dir_under_a_symlink_loop_is_refused(tmp_path, command): + """The `--work-dir` is no link, but the way to it runs through a link + to itself: the reason says so and names that link, and the way out is + to remove or mend it. On main at 1a47ed07 it said only `Too many levels + of symbolic links`, with the way out for a folder this user cannot + write in.""" + planned(tmp_path, {}, {}, {}) + linked(tmp_path / "loop", "loop") + loop = real(tmp_path, "loop") + refused_for_a_link(command, tmp_path, tmp_path / "loop" / "sub", + through_a_loop(loop), loop) + + +@pytest.fixture(scope="module") +def adopter(): + """`adopt-project.py` loaded as a module, for `_the_loop_in`.""" + return load_without_bytecode(ADOPT, "adopt_work_dir_refusal") + + +def test_only_a_symlink_that_loops_is_called_a_loop(adopter, tmp_path): + """`_the_loop_in` names the symlink in a path that leads round a loop, + and no other (#978). `os.path.realpath` leaves only such a link in the + path it returns, and the system is asked all the same (ELOOP), so that + a link left there for any other reason, one to a file or to a + directory, is never called a loop.""" + (tmp_path / "a-dir").mkdir() + (tmp_path / "a-file").write_text(MINE) + for name, target in (("loop", "loop"), ("to-file", "a-file"), + ("to-dir", "a-dir")): + linked(tmp_path / name, target) + loop = real(tmp_path, "loop") + assert adopter._the_loop_in(loop) == loop + assert adopter._the_loop_in(loop / "sub") == loop + for name in ("to-file", "to-dir/sub", "a-dir/sub"): + assert adopter._the_loop_in(real(tmp_path, name)) is None, name + + +@pytest.mark.parametrize("command", COMMANDS) +def test_a_work_dir_that_is_a_symlink_to_a_file_is_refused(tmp_path, + command): + """A symlink at the `--work-dir` that leads to a file is no loop, and + is not said to be one: the reason names the link and the file it leads + to, and the way out the link. On main at 1a47ed07 it named the file + alone, as if it were the `--work-dir`, with `File exists`. A file AT + the `--work-dir` is still `File exists` (above).""" + planned(tmp_path, {}, {}, {}) + a_file = tmp_path / "a-file" + a_file.write_text(MINE) + work = tmp_path / "work" + linked(work, a_file.name) + refused_for_a_link( + command, tmp_path, work, + f"it is a symbolic link that leads to " + f"{ticked(str(real(tmp_path, a_file.name)))}, which is not a " + "directory", work) + assert a_file.read_text() == MINE + + +@pytest.mark.parametrize("command", (PLAN_VERB, CHECK)) +@pytest.mark.parametrize("target", ["target-a", "missing-dir/target-b"]) +def test_a_dangling_symlink_work_dir_is_made_where_it_leads(tmp_path, + command, target): + """CONTROL, as on main: a `--work-dir` that is a symlink to nothing is + read with `os.path.realpath` as the path it leads to, which is made, + with any directory missing above it, and the run goes on. So a dangling + link never meets #978's refusal (r903's measure on #963).""" + planned(tmp_path, {}, {}, {}) + work = tmp_path / "work" + linked(work, target) + result = run_in(command, tmp_path, work) + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / target).is_dir() + assert os.readlink(work) == target @pytest.mark.parametrize("command", COMMANDS)