From 7f7e1b446c73c093a1dbe62971d4ab3c33f42b54 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 18:56:24 +0000 Subject: [PATCH 1/4] scaffold-project.py and family.py: both runs ask whether each folder they make can be made and take only gh's not-found answer as "absent", so `--dry-run` refuses a file where a folder goes, or a `gh repo view` that fails otherwise, by name as the real run does, and a refused `init` leaves no empty family folder (#609) #459's review of #573 found three lines, measured on main 26c1600 and at #573's head; this branch is cut from main 2989b4c, where #573 has landed. - L2-34. Neither dry run asked `scaffold-target-exists` or `family-target-exists`, or whether `--work-dir`, `--local-remote-dir` or the family folder could be made. In the seven cases measured (four scaffold, three `init`) each dry run exited 0 with `--dry-run: nothing was created.`, and the real runs gave two clean refusals and five `FileExistsError`/`NotADirectoryError` tracebacks. - L2-36. Any `gh repo view` failure read as "the repository does not exist": with a gh answering `HTTP 401: Bad credentials` both dry runs exited 0, the real scaffold failed at `gh repo create`, and the real `init` left an empty family folder. - L2-35. A real `init` made `//` before it asked whether the holder's remote exists, so a refusal left that folder empty. Both tools now ask one question before anything is made, in the dry run and the real run alike: every folder the run makes and each working tree it writes, then every remote. The questions are one definition in `scripts/shape_materialize.py`, which is not a shape copy, so nothing is re-pinned: - `folder_cannot_be_made` / `refuse_unmakeable_folder`: a file at the folder or above it, or a folder this user cannot write in, is `scaffold-folder-cannot-be-made` / `family-folder-cannot-be-made`; - `target_in_the_way`: a file where a working tree goes is `scaffold-target-exists` / `family-target-exists`, beside the non-empty folder both already refused; - `refuse_unless_not_found`: only exit 1 with gh's `Could not resolve to a Repository` (gh 2.x's words for GitHub's NOT_FOUND) means absent; any other failure is `remote-unreadable`, naming the command, its exit and gh's first line; - `make_folder`: the real run's own `mkdir`, after the question, refuses in the system's words rather than raising. The real runs make their folders only after the remote question, so a refused `init` leaves no family folder and a refused scaffold no work directory. `--dry-run`'s help in both tools says so, and `docs/cli.md` is regenerated. `tests/test_scaffold_manifest_values.py`'s fake gh now answers `repo view` as GitHub answers for a repository it does not have, the one failure the scaffold still reads as absent. Tests: `tests/test_dry_run_parity.py`, 43, of which 41 are red on main 2989b4c (the two not-found controls pass there, as they must). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 20 +- scaffold-project.py | 109 +++-- scripts/family.py | 122 +++-- scripts/shape_materialize.py | 175 +++++++ tests/test_dry_run_parity.py | 610 +++++++++++++++++++++++++ tests/test_scaffold_manifest_values.py | 13 +- 6 files changed, 983 insertions(+), 66 deletions(-) create mode 100644 tests/test_dry_run_parity.py diff --git a/docs/cli.md b/docs/cli.md index cb7784f4..d0a669b5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -306,10 +306,12 @@ options: is refused before anything is created: `project.yaml` is one, and `contracts` holds several. --dry-run print the plan and ask, as the real run does, whether - each of the three repositories already exists (`gh - repo view` and its commit listing, or the bare - repositories with --local-remote-dir), refusing what - it refuses; create no remote and push nothing + each folder it makes can be made (--work-dir, the + trees in it, --local-remote-dir) and whether each of + the three repositories already exists (`gh repo view` + and its commit listing, or the bare repositories with + --local-remote-dir), refusing what it refuses; make no + folder, create no remote and push nothing --local-remote-dir LOCAL_REMOTE_DIR create bare repositories here instead of calling `gh repo create` (the TEST path; no network) @@ -739,10 +741,12 @@ options: with --into. --no-push --dry-run print the plan and ask, as the real run does, whether - / already exists (`gh repo view` and its - commit listing; with --local-remote-dir, the bare - repository), refusing what it refuses; make no folder, - remote or repository + each folder it makes can be made (the family folder or + --work-dir, the holder in it, --local-remote-dir) and + whether / already exists (`gh repo view` + and its commit listing; with --local-remote-dir, the + bare repository), refusing what it refuses; make no + folder, remote or repository ``` #### `scripts/family.py add` diff --git a/scaffold-project.py b/scaffold-project.py index 5f25838c..1f71927f 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -59,7 +59,13 @@ organisation's ruleset refused left behind. It never deletes, rewrites or force-pushes a remote. `--dry-run` asks the same question of the three remotes and refuses `scaffold-remote-exists` as the real run does (#537), so -`setup-project.py` refuses at the plan, before it asks for the yes. +`setup-project.py` refuses at the plan, before it asks for the yes. Both +runs ask it after the folders, and before either makes one (#609): a file +where `--work-dir`, `--local-remote-dir` or a working tree must go, or a +folder this user cannot write in, is `scaffold-folder-cannot-be-made` or +`scaffold-target-exists`, never a traceback. Only gh's own not-found answer +says a repository does not exist; any other `gh repo view` failure (a bad +login, a rate limit, no network) is refused as `remote-unreadable`. EXIT CODES: 0 done · 1 nothing (dry run prints and exits 0) · 2 refusal. """ @@ -111,6 +117,13 @@ # The project's own reader, and the writer of the double-quoted scalars it # reads back, asked of each `neutral_product_pins:` entry (#516). from repo_shape import YamlError, double_quoted, parse_yaml # noqa: E402 +# What both runs ask before the first write, as `family.py init` asks it +# (#609): each folder this run makes, each working tree it writes, and gh's +# not-found answer as the one failure of `gh repo view` that means absent. +from shape_materialize import ( # noqa: E402 + make_folder, refuse_unless_not_found, refuse_unmakeable_folder, + target_in_the_way, +) #: How a refusal names each value the manifest records (#258, #261). The leg #: paths are not here: `refuse_bad_leg_paths` asks for them (#179, #225). @@ -164,6 +177,11 @@ def remote_is_empty(repository: str) -> bool | None: NO `gh` ON PATH IS `gh-not-found` (#537), and not a `FileNotFoundError` traceback: `--dry-run` asks this question too now, and before that a dry run needed no `gh` at all. + + ONLY GH'S NOT-FOUND ANSWER IS "IT DOES NOT EXIST" (#609). Any failure of + `gh repo view` used to be, so a bad login, a rate limit or an outage + planned under `--dry-run` and sent the real run on to `gh repo create`; + `refuse_unless_not_found` refuses every other failure by name. """ try: probe = subprocess.run( @@ -174,6 +192,7 @@ def remote_is_empty(repository: str) -> bool | None: raise _gh_not_found( f"whether {repository} already exists on GitHub", exc) from exc if probe.returncode != 0: + refuse_unless_not_found(repository, probe) return None commits = subprocess.run( ["gh", "api", f"repos/{repository}/commits?per_page=1"], @@ -300,10 +319,13 @@ def main(argv: list[str] | None = None) -> int: "`contracts` holds several.") parser.add_argument("--dry-run", action="store_true", help="print the plan and ask, as the real run does, " - "whether each of the three repositories already " - "exists (`gh repo view` and its commit listing, " - "or the bare repositories with " - "--local-remote-dir), refusing what it refuses; " + "whether each folder it makes can be made " + "(--work-dir, the trees in it, " + "--local-remote-dir) and whether each of the " + "three repositories already exists (`gh repo " + "view` and its commit listing, or the bare " + "repositories with --local-remote-dir), " + "refusing what it refuses; make no folder, " "create no remote and push nothing") parser.add_argument("--local-remote-dir", type=Path, default=None, help="create bare repositories here instead of calling " @@ -1121,21 +1143,54 @@ def _print_plan_actions(args, topic: str, local: bool) -> None: else f"gh repo edit --add-topic {topic}")) +#: The code of every folder this run makes and cannot (#609). +FOLDER_REFUSAL = "scaffold-folder-cannot-be-made" + + def _refuse_existing_trees(work_root: Path, names: dict) -> None: """(j) Refuse to write over a working tree that already has something in it. Split out of `_scaffold` for #138. + + A FILE THERE IS REFUSED TOO (#609): `iterdir()` raised + `NotADirectoryError` on one, a traceback after `--dry-run` had exited 0. + `target_in_the_way` asks both, and makes nothing. """ for role, name in names.items(): target = work_root / name - if target.exists() and any(target.iterdir()): + why = target_in_the_way(target) + if why: raise Refusal( "scaffold-target-exists", - f"{target} already exists and is not empty", + f"{target} already exists and {why}", "Remediation: choose an empty --work-dir. There is no --force: " "re-running over a live tree is not a scaffold.", ) +def _refuse_unmakeable_folders(args, work_root: Path | None, names: dict, + local: bool) -> None: + """(j′) Every folder this run makes, and each working tree it writes, + asked before anything is made, by the dry run and the real run alike + (#609): `--work-dir`, then the three trees in it, then + `--local-remote-dir`, the order the real run makes them in. + + THE DRY RUN ASKED NONE OF IT. With a file where `--work-dir` or + `--local-remote-dir` goes, or where a tree goes, `--dry-run` exited 0 + and the real run died in `mkdir` or `iterdir()` with a traceback. A + folder that cannot be made, because a file stands at it or above it or + this user cannot write where it goes, is `scaffold-folder-cannot-be-made` + now, and the real run's own `mkdir` is `make_folder`, which refuses the + same in the system's words. With no `--work-dir` the run makes a fresh + temporary directory, which nothing stands in. + """ + if work_root is not None: + refuse_unmakeable_folder(FOLDER_REFUSAL, "--work-dir", work_root) + _refuse_existing_trees(work_root, names) + if local: + refuse_unmakeable_folder(FOLDER_REFUSAL, "--local-remote-dir", + args.local_remote_dir.resolve()) + + def _create_remotes(args, values: dict, repositories: dict, urls: dict, local: bool, reused: dict) -> dict[str, str]: """(k) The three remotes, spec and code first so a failure costs the @@ -1634,25 +1689,18 @@ def _scaffold(args) -> int: _print_plan(args, values, names, repositories, urls, local, policy, declared_pins, chain, link_pins, pins) - if args.dry_run: - # THE DRY RUN ASKS WHETHER THE REMOTES EXIST (#537), with the real - # run's own question, `_reusable_remotes`, and refuses what it - # refuses: `scaffold-remote-exists` used to come only from the real - # run, which `setup-project.py` starts after the yes. It READS: `gh - # repo view` and a commit listing, or the bare repositories on disk - # with `--local-remote-dir`, and for a leg that is not empty the one - # commit it holds, fetched into a temporary directory it deletes. It - # creates no remote and pushes nothing. - _reusable_remotes(names, urls, repositories, local, - args.reuse_empty_repo, values, args.tracking_branch) - print("\n--dry-run: nothing was created.") - return 0 # ---- refuse to write over anything that already exists ---------------- - work_root = (args.work_dir.resolve() if args.work_dir - else Path(tempfile.mkdtemp(prefix="openreposhape-"))) - work_root.mkdir(parents=True, exist_ok=True) - _refuse_existing_trees(work_root, names) + # ONE QUESTION, ASKED BY BOTH RUNS BEFORE ANYTHING IS MADE (#537, #609): + # every folder this run makes and each tree it writes, then every + # remote. It READS: the folders, then `gh repo view` and a commit + # listing, or the bare repositories on disk with `--local-remote-dir`, + # and for a leg that is not empty the one commit it holds, fetched into a + # temporary directory it deletes. So `--dry-run` refuses what the real + # run refuses, which `setup-project.py` asks before the yes, and the + # real run makes no folder for a run it then refuses. + work_root = args.work_dir.resolve() if args.work_dir else None + _refuse_unmakeable_folders(args, work_root, names, local) # A REPOSITORY MAY ALREADY EXIST only with `--reuse-empty-repo`, and only # with ZERO commits, or, for a leg, with this scaffold's own seed as its # one commit (#462). An organisation that creates the repository first and @@ -1662,11 +1710,20 @@ def _scaffold(args) -> int: # operator holding the bypass right. A root with commits is refused # exactly as before: that is a live project, and `adopt-project.py` is the # tool for one of those. See `_reusable_remotes`. - if local: - args.local_remote_dir.resolve().mkdir(parents=True, exist_ok=True) reused = _reusable_remotes(names, urls, repositories, local, args.reuse_empty_repo, values, args.tracking_branch) + if args.dry_run: + print("\n--dry-run: nothing was created.") + return 0 + + if work_root is None: + work_root = Path(tempfile.mkdtemp(prefix="openreposhape-")) + else: + make_folder(FOLDER_REFUSAL, "--work-dir", work_root) + if local: + make_folder(FOLDER_REFUSAL, "--local-remote-dir", + args.local_remote_dir.resolve()) standing = _create_remotes(args, values, repositories, urls, local, reused) _private_leg_note(args, repositories, local, standing) diff --git a/scripts/family.py b/scripts/family.py index 7c3b57b5..3d527ab3 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -90,7 +90,14 @@ question the real run asks before it creates the holder (#537): `gh repo view` and the commit listing, or the bare repository with `--local-remote-dir`. It refuses `family-remote-exists` as the real run does, -and makes no folder, remote or repository. +and makes no folder, remote or repository. Both runs ask it after whether +each folder `init` makes can be made and the holder written (#609), and +before the real run makes the family folder, so a refusal leaves none: a file +where a folder must go, or a folder this user cannot write in, is +`family-folder-cannot-be-made` or `family-target-exists`, never a traceback. +Only gh's own not-found answer says the holder does not exist; any other +`gh repo view` failure (a bad login, a rate limit, no network) is refused as +`remote-unreadable`. WHAT ONLY THE WRITE ITSELF CAN ANSWER IS NOT ASKED. A member whose local changes `git checkout` would refuse to overwrite gets exit 0 and a row from @@ -147,8 +154,10 @@ from shape_materialize import ( # noqa: E402 FAMILY_RULESET_HINT, SHAPE_REPOSITORY, CommandFailed, check_program, commit_trailers, env_commit, escape_what_stdout_cannot_encode, - force_stage, inherited_child_environment, materialize_family_root, - refuse_bad_branch, run, stage_written, trailer_line, write_lf, + force_stage, inherited_child_environment, make_folder, + materialize_family_root, refuse_bad_branch, refuse_unless_not_found, + refuse_unmakeable_folder, run, stage_written, target_in_the_way, + trailer_line, write_lf, ) # What `init` asks of the holder's own reader and validator before it creates # anything (#307), imported as its own unit and not added to the lists above. @@ -851,6 +860,12 @@ def _remote_is_empty(repository: str) -> bool | None: NO `gh` ON PATH IS `gh-not-found` (#537), as `pin_digest_from_gh` names it, and not a `FileNotFoundError` traceback: `init --dry-run` asks this question too now, and before that a dry run needed no `gh` at all. + + ONLY GH'S NOT-FOUND ANSWER IS "IT DOES NOT EXIST" (#609), as the + scaffold's `remote_is_empty` reads it. Any failure of `gh repo view` used + to be, so with a bad login `init --dry-run` exited 0 and the real run + made its family folder and failed at `gh repo create`; + `refuse_unless_not_found` refuses every other failure by name. """ try: probe = subprocess.run( @@ -867,6 +882,7 @@ def _remote_is_empty(repository: str) -> bool | None: "create`. `openRepoShape --preflight` names each missing " "prerequisite and offers to install it, on a typed yes.") from exc if probe.returncode != 0: + refuse_unless_not_found(repository, probe) return None commits = subprocess.run( ["gh", "api", f"repos/{repository}/commits?per_page=1"], @@ -1198,24 +1214,68 @@ def _print_init_plan(values: dict, repository: str, url: str, else f"gh repo edit --add-topic {topic}")) +#: The code of every folder `init` makes and cannot (#609). +FOLDER_REFUSAL = "family-folder-cannot-be-made" + + +def _landing_folder(folder: Path | None, holder: Path) -> tuple[Path, str]: + """`(the folder init makes the holder in, how the person names it)`: + the family folder, or with `--work-dir` that directory itself.""" + if folder is None: + return holder.parent, "--work-dir" + return folder, "the family folder" + + +def _refuse_a_taken_holder(holder: Path) -> None: + """`family-target-exists`: something already stands where the holder + goes, a folder that holds anything or, since #609, a file, which + `iterdir()` met with a `NotADirectoryError` traceback.""" + why = target_in_the_way(holder) + if why: + raise Refusal( + "family-target-exists", f"{holder} already exists and {why}", + "Remediation: choose another --into (or an empty --work-dir); if " + "that IS the holder, it is already a family and `family.py add` " + "is what grows it. There is no --force.") + + +def _refuse_unmakeable_landing(args, folder: Path | None, holder: Path, + local: bool) -> None: + """Every folder `init` makes, and the holder it writes, asked before + anything is made, by the dry run and the real run alike (#609): the + family folder (or `--work-dir`), the holder in it, `--local-remote-dir`. + + THE DRY RUN ASKED NONE OF IT. With a file where the family folder, the + holder or `--local-remote-dir` goes, `init --dry-run` exited 0 and the + real run died in `mkdir` or `iterdir()` with a traceback. A folder that + cannot be made, because a file stands at it or above it or this user + cannot write where it goes, is `family-folder-cannot-be-made` now, and + the real run's own `mkdir` is `make_folder`, which refuses the same in + the system's words. + """ + landing, named = _landing_folder(folder, holder) + refuse_unmakeable_folder(FOLDER_REFUSAL, named, landing) + _refuse_a_taken_holder(holder) + if local: + refuse_unmakeable_folder(FOLDER_REFUSAL, "--local-remote-dir", + args.local_remote_dir.resolve()) + + def _prepare_landing_dir(folder: Path | None, holder: Path) -> Path: - """Make the landing directory, refused if the holder's target already - exists and is not empty. + """Make the landing directory, refused if something already stands + where the holder goes (`_refuse_a_taken_holder`). - Split from `cmd_init` for #136. + Split from `cmd_init` for #136. Run only after `_refuse_unmakeable_landing` + and `_checked_reuse` have said yes (#609), so a refused run leaves no + folder; the holder is asked again, as it stands once the folder is made. """ # `mkdir -p` on the FOLDER, which is fine if it is already there — a # family folder somebody made by hand, or one a scaffolded member already # landed in, is exactly the case this is supposed to join. - work = holder - (folder or work.parent).mkdir(parents=True, exist_ok=True) - if work.exists() and any(work.iterdir()): - raise Refusal( - "family-target-exists", f"{work} already exists and is not empty", - "Remediation: choose another --into (or an empty --work-dir); if " - "that IS the holder, it is already a family and `family.py add` " - "is what grows it. There is no --force.") - return work + landing, named = _landing_folder(folder, holder) + make_folder(FOLDER_REFUSAL, named, landing) + _refuse_a_taken_holder(holder) + return holder def _checked_reuse(reuse_empty_repo: bool, local: bool, url: str, @@ -1259,7 +1319,7 @@ def _create_holder_remote(args, values: dict, local: bool, url: str, if reuse: return if local: - Path(url).parent.mkdir(parents=True, exist_ok=True) + make_folder(FOLDER_REFUSAL, "--local-remote-dir", Path(url).parent) run(["git", "init", "-q", "--bare", "-b", args.tracking_branch, url]) print(f" bare {url}") else: @@ -1755,19 +1815,21 @@ def cmd_init(args) -> int: folder, holder = _checked_landing(args, family) _print_init_plan(values, repository, url, shape_commit, shape_tree, folder, holder, local, topic, args.reuse_empty_repo) + # ONE QUESTION, ASKED BY BOTH RUNS BEFORE ANYTHING IS MADE (#537, #609): + # every folder `init` makes and the holder it writes, then whether the + # holder's remote exists, with the real run's own `_checked_reuse`. It + # READS: the folders, then a `gh repo view` and a commit listing, or the + # bare repository on disk with `--local-remote-dir`. So `--dry-run` + # refuses what the real run refuses, and the real run asks it all + # before `_prepare_landing_dir` makes the family folder: a refusal left + # that folder empty behind it (#537's first line). + _refuse_unmakeable_landing(args, folder, holder, local) + reuse = _checked_reuse(args.reuse_empty_repo, local, url, repository) if args.dry_run: - # THE DRY RUN ASKS WHETHER THE HOLDER'S REMOTE EXISTS (#537), with - # the real run's own question, `_checked_reuse`, and refuses what it - # refuses: `family-remote-exists` used to come only from the real - # run, after `_prepare_landing_dir` had made the family folder. It - # READS, a `gh repo view` and a commit listing, or the bare - # repository on disk with `--local-remote-dir`, and creates nothing. - _checked_reuse(args.reuse_empty_repo, local, url, repository) print("\n--dry-run: nothing was created.") return 0 work = _prepare_landing_dir(folder, holder) - reuse = _checked_reuse(args.reuse_empty_repo, local, url, repository) _create_holder_remote(args, values, local, url, repository, reuse) code = _create_holder_worktree(args, work, values, shape_commit, @@ -4985,11 +5047,13 @@ def build_parser() -> argparse.ArgumentParser: init.add_argument("--no-push", action="store_true") init.add_argument("--dry-run", action="store_true", help="print the plan and ask, as the real run does, " - "whether / already exists (`gh repo " - "view` and its commit listing; with " - "--local-remote-dir, the bare repository), " - "refusing what it refuses; make no folder, " - "remote or repository") + "whether each folder it makes can be made (the " + "family folder or --work-dir, the holder in it, " + "--local-remote-dir) and whether / " + "already exists (`gh repo view` and its commit " + "listing; with --local-remote-dir, the bare " + "repository), refusing what it refuses; make no " + "folder, remote or repository") init.set_defaults(func=cmd_init) add = subparsers.add_parser( diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index e0c93d6d..0e924512 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -1263,6 +1263,181 @@ def refuse_bad_branch(refusal: str, what: str, value) -> None: raise Refusal(refusal, problem, remediation) +# --------------------------------------------------------------------------- +# What a run asks before its first write: the folders it makes (#609) +# --------------------------------------------------------------------------- +# +# `scaffold-project.py` and `family.py init` each make folders -- the work +# directory, `--local-remote-dir`, a family folder -- and write a working tree +# into one, and their dry runs asked about none of it. With a file standing +# where a folder must go the dry run exited 0 and the real run died in +# `mkdir` (`FileExistsError`) or in the target-exists question itself +# (`NotADirectoryError`), a traceback, exit 1. These are the questions both +# runs now ask, before anything is made, and the `mkdir` the real run makes +# after them. They live here and not in `scripts/repo_shape.py` for the reason +# `refuse_bad_branch` does: that file is a SHAPE COPY. + +#: Said by every refusal of a folder asked about before anything is made. +FOLDER_REMEDIATION = ( + "Remediation: nothing was created, and `--dry-run` refuses the same. " + "Move what stands in the way aside, or pass a path where a folder can be " + "made. There is no --force.") + +#: Said when the real run's own `mkdir` fails, after the question said yes: +#: the run has made folders by then, and no remote. +MKDIR_REMEDIATION = ( + "Remediation: no remote was created. Move what stands in the way aside, " + "or pass a path where a folder can be made, then run this again. There " + "is no --force.") + + +def _not_a_folder(path: Path) -> str | None: + """Why the thing that stands at `path` is no folder to make one in or + write into, or None when it is a folder (a link to one included). + + `stat()` INSIDE THE GUARD judges it, as `_digest_from_pin_source` does: + a link to nothing, a link loop or a path this user cannot search is the + system's own reason, where `is_dir()` would have said False or raised.""" + try: + mode = path.stat().st_mode + except OSError as exc: + return f"cannot be used as a folder: {exc.strerror or exc}" + if stat.S_ISDIR(mode): + return None + return ("is a file, not a folder" if stat.S_ISREG(mode) + else "is not a folder") + + +def folder_cannot_be_made(path: Path) -> str | None: + """Why `path` cannot be made as a folder, with every folder above it, and + written in, or None when it can (#609). It makes nothing. + + The question `Path.mkdir(parents=True, exist_ok=True)` and the first + write into the folder ask, put without making anything: the nearest of + `path` and the folders above it that exists is the one that answers. A + file there, or above it, is named, and so is a folder this user cannot + write in (the system's own answer, `os.access`, where it has one; it + says yes for root, and for any folder on Windows, as `mkdir` would). A + `path` nothing stands at is asked of the folder above it. + """ + for here in (path, *path.parents): + if not os.path.lexists(here): + continue + why = _not_a_folder(here) + if why: + return f"{here} {why}" + if not os.access(here, os.W_OK | os.X_OK): + return f"{here} is a folder this user cannot write in" + return None + return None + + +def refuse_unmakeable_folder(refusal: str, named: str, path: Path) -> None: + """Raise `Refusal(refusal)` when `folder_cannot_be_made(path)` says why. + + `refusal` is the caller's own code (`scaffold-folder-cannot-be-made`) + and `named` how the person met the folder (`--work-dir`, `the family + folder`), so each tool refuses in its own voice. Asked by the dry run + and the real run alike, before anything is made.""" + why = folder_cannot_be_made(path) + if why: + raise Refusal(refusal, f"{named} {path} cannot be made: {why}", + FOLDER_REMEDIATION) + + +def make_folder(refusal: str, named: str, path: Path) -> None: + """`path.mkdir(parents=True, exist_ok=True)`, and a refusal in the + system's own words when it fails, never an `OSError` traceback. + + The real run's `mkdir`, after `refuse_unmakeable_folder` has said yes: + what still fails here (a file made in the meantime, a permission + `os.access` did not foresee) is the same refusal, before any remote is + made.""" + try: + path.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise Refusal(refusal, f"{named} {path} cannot be made: " + f"{exc.strerror or exc}", MKDIR_REMEDIATION) from exc + + +def target_in_the_way(target: Path) -> str | None: + """Why a working tree cannot be written at `target`, which a run fills + and never empties, or None when nothing is there or an empty folder is + (#609). The caller's sentence is ` already exists and `. + + A FILE THERE was a `NotADirectoryError` out of `target.iterdir()` in + the real run, after its dry run exited 0. It is said here, and so is a + folder that holds anything, which both tools always refused, and one + this user cannot list.""" + if not os.path.lexists(target): + return None + why = _not_a_folder(target) + if why: + return why + try: + if any(target.iterdir()): + return "is not empty" + except OSError as exc: + return f"cannot be read: {exc.strerror or exc}" + return None + + +# --------------------------------------------------------------------------- +# What a run asks before its first write: whether a repository exists (#609) +# --------------------------------------------------------------------------- + +#: The words of gh's answer for a repository GitHub does not have, on stderr +#: with exit 1: `gh repo view` asks GitHub's GraphQL API, which answers +#: `Could not resolve to a Repository with the name '/'.`, and +#: gh prints it as `GraphQL: (repository)` (gh 2.x; its +#: `FetchRepository` says the same when the API returns no repository). +GH_NOT_FOUND_WORDS = "Could not resolve to a Repository" + +#: Said by every refusal of a `gh repo view` that failed otherwise. +GH_UNREADABLE_REMEDIATION = ( + "Remediation: nothing was created, and `--dry-run` refuses the same. " + "Only gh's own answer that a repository does not exist (exit 1, " + f"`{GH_NOT_FOUND_WORDS}`) lets a run go on as if it were not there; any " + "other failure says nothing about whether it is. Check `gh auth status` " + "(`gh auth login` where it names a token that is missing, bad or " + "expired), or let a rate limit or an outage pass, then run this again.") + + +def refuse_unless_not_found(repository: str, probe) -> None: + """A `gh repo view ` that FAILED is gh's answer that the + repository does not exist, and this returns; any other failure is + refused as `remote-unreadable` (#609). + + ANY FAILURE USED TO MEAN "ABSENT". With a bad token (`HTTP 401: Bad + credentials`), a rate limit or no network, both dry runs exited 0 and + the real runs went on to `gh repo create`: the scaffold failed there, + and `init` had made its family folder first. A repository that DOES + exist, read through a passing failure, would have had the scaffold + create and push both legs before its root met `gh repo create`. So only + exit 1 with `GH_NOT_FOUND_WORDS` on stderr means absent; the refusal + quotes the command, its exit and the first line gh said (stdout's when + stderr is empty), as Python spells it when it is not printable. + + `probe` is the finished `subprocess.CompletedProcess` of the command; + one that exited 0 is not asked about here. + """ + if probe.returncode == 1 and GH_NOT_FOUND_WORDS in (probe.stderr or ""): + return + lines = [line.strip() + for line in (probe.stderr or probe.stdout or "").splitlines() + if line.strip()] + said = lines[0] if lines else "" + if not said.isprintable(): + said = repr(said) + command = " ".join(str(word) for word in probe.args) + raise Refusal( + "remote-unreadable", + f"whether {repository} already exists on GitHub cannot be told: " + f"`{command}` exited {probe.returncode}" + + (f": {said}" if said else ""), + GH_UNREADABLE_REMEDIATION) + + #: `-F -` reads the message from STDIN. Every argument is then a literal, and #: the one value a caller controls — the message, which carries a project's #: display name and, in an adoption, every moved path out of a plan file — diff --git a/tests/test_dry_run_parity.py b/tests/test_dry_run_parity.py new file mode 100644 index 00000000..76c4fbc9 --- /dev/null +++ b/tests/test_dry_run_parity.py @@ -0,0 +1,610 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The scaffold's and `family.py init`'s dry runs ask what their real runs +refuse before the first write, and the real runs refuse it by name (#609). + +WHAT WAS WRONG, three lines of #459's review of #573: +- L2-34. Neither dry run asked `scaffold-target-exists` or + `family-target-exists`, or whether `--work-dir`, `--local-remote-dir` or + the family folder could be made. In the seven cases measured (four + scaffold, three `init`) each dry run exited 0 with `--dry-run: nothing was + created.`, and the real runs gave two clean refusals and five tracebacks + (`FileExistsError` where a file stood where a folder must go, + `NotADirectoryError` where a file stood where a working tree goes). +- L2-36. Any `gh repo view` failure read as "the repository does not exist": + with a `gh` answering `HTTP 401: Bad credentials` both dry runs exited 0, + the real scaffold failed at `gh repo create`, and the real `init` left an + empty family folder. +- L2-35. A real `init` made `//` before it asked whether the + holder's remote exists, so a refusal left that folder empty behind it. + +THE CURE. One question, asked by both runs before anything is made: every +folder the run makes (`shape_materialize.folder_cannot_be_made`), each +working tree it writes into (`target_in_the_way`), then every remote, where +only gh's own not-found answer means "absent" +(`refuse_unless_not_found`). The real run makes its folders only after all +of it, and a folder that still cannot be made is refused by name +(`make_folder`). + +The GitHub route runs against a stub `gh` on PATH that answers `repo view` +as the test says and refuses anything else, and is POSIX only, as every +`#!` stub in this suite is. +""" + +from __future__ import annotations + +import os +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import ORG, REPO, SCAFFOLD, WINDOWS_SKIP, run_script +from test_dry_run_asks_remotes import (DRY_RUN_DONE, live_bare, load, + no_gh_run, scratch_env, without_gh) + +sys.path.insert(0, str(REPO / "scripts")) +import shape_materialize # noqa: E402 +from repo_shape import Refusal # noqa: E402 + +FAMILY = REPO / "scripts" / "family.py" +PROJECT = "Atlas" +HOLDER = "Orbit" +SCAFFOLD_FOLDER = "REFUSED scaffold-folder-cannot-be-made" +FAMILY_FOLDER = "REFUSED family-folder-cannot-be-made" + + +def touch(path: Path) -> None: + """A file at `path`, somebody's, made with every folder above it.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"somebody's\n") + + +def snapshot(root: Path, leave_out=("ghbin", "gh.log")) -> dict: + """Every path under `root` and what it is (a folder, a file's bytes, a + link's target), so a test sees whether a run made, changed or removed + anything. `leave_out` is what the test's own `gh` stub writes.""" + seen: dict = {} + for here, folders, files in os.walk(root): + folders[:] = [name for name in folders if name not in leave_out] + for name in folders + [name for name in files + if name not in leave_out]: + path = Path(here) / name + key = path.relative_to(root).as_posix() + seen[key] = (("link", os.readlink(path)) if path.is_symlink() + else ("folder",) if path.is_dir() + else ("file", path.read_bytes())) + return seen + + +def scaffold(tmp_path: Path, *extra: str, dry_run: bool, + env: dict | None = None): + return run_script( + SCAFFOLD, "--org", ORG, "--project", PROJECT, + "--elected-by", "Test Human", "--elected-on", "2026-09-02", *extra, + *(["--dry-run"] if dry_run else []), + env={**scratch_env(tmp_path), **(env or {})}) + + +def family_init(tmp_path: Path, *extra: str, dry_run: bool, + env: dict | None = None): + return run_script( + FAMILY, "init", "--org", ORG, "--family", HOLDER, + "--created-by", "Test Human", "--created-on", "2026-09-02", *extra, + *(["--dry-run"] if dry_run else []), + env={**scratch_env(tmp_path), **(env or {})}) + + +def refused_line(result) -> str: + """The `REFUSED` line a run ended on, whatever WARNING came before it.""" + return next((line for line in result.stderr.splitlines() + if line.startswith("REFUSED ")), "") + + +def assert_refused(result, said: str) -> None: + """Exit 2 with the refusal `said`, its whole line, and no traceback.""" + assert result.returncode == 2, result.stdout + result.stderr + assert refused_line(result) == said, result.stderr + assert "Traceback" not in result.stderr, result.stderr + assert DRY_RUN_DONE not in result.stdout, result.stdout + + +def assert_both_runs_refuse(tmp_path: Path, run, said: str): + """THE ISSUE'S CLAIM, for one case: the dry run refuses `said`, the real + run refuses it in the same words after the same plan, and neither makes, + changes or removes anything under `tmp_path`, a temporary directory of + its own included. Returns the real run.""" + scratch_env(tmp_path) + before = snapshot(tmp_path) + dry = run(dry_run=True) + assert_refused(dry, said) + assert snapshot(tmp_path) == before, "the dry run made something" + real = run(dry_run=False) + assert_refused(real, said) + assert (real.stdout, real.stderr) == (dry.stdout, dry.stderr) + assert snapshot(tmp_path) == before, ( + "the real run made something before it refused") + return real + + +# --- L2-34: the seven cases measured, and the ones beside them ---------------- + +def scaffold_in(tmp_path: Path, work: str = "wd", remotes: str = "r"): + """A scaffold run with `--work-dir ` and `--local-remote-dir + ` under `tmp_path`, as `assert_both_runs_refuse` calls it.""" + def run(dry_run: bool): + return scaffold(tmp_path, "--work-dir", str(tmp_path / work), + "--local-remote-dir", str(tmp_path / remotes), + dry_run=dry_run) + return run + + +def init_in(tmp_path: Path, *landing: str, remotes: str = "r"): + """A `family.py init` run landing as `landing` says (`--into ` by + default) with `--local-remote-dir ` under `tmp_path`.""" + flags = ([landing[0], str(tmp_path / landing[1])] if landing + else ["--into", str(tmp_path / "into")]) + + def run(dry_run: bool): + return family_init(tmp_path, *flags, "--local-remote-dir", + str(tmp_path / remotes), dry_run=dry_run) + return run + + +def at(tmp_path: Path, relative: str) -> Path: + """A path under `tmp_path` as the tools name it: resolved.""" + return (tmp_path / relative).resolve() + + +#: The scaffold's four measured cases and two beside them: `(what the test +#: puts in the way, the --work-dir and --local-remote-dir it passes, the +#: REFUSED line both runs end on)`. On main 2989b4c each dry run exited 0; +#: the real runs raised `FileExistsError` (a file at the work dir or the +#: remote dir), `NotADirectoryError` (a file named for the root inside the +#: work dir), or `FileExistsError`/`NotADirectoryError` from a file above +#: either, and refused only the non-empty tree. +SCAFFOLD_CASES = { + "work-dir-is-a-file": ( + "wd", ("wd", "r"), + lambda t: f"{SCAFFOLD_FOLDER}: --work-dir {at(t, 'wd')} cannot be " + f"made: {at(t, 'wd')} is a file, not a folder"), + "work-dir-holds-a-file-named-Atlas": ( + "wd/Atlas", ("wd", "r"), + lambda t: f"REFUSED scaffold-target-exists: {at(t, 'wd/Atlas')} " + "already exists and is a file, not a folder"), + "work-dir-holds-a-nonempty-Atlas": ( + "wd/Atlas/f", ("wd", "r"), + lambda t: f"REFUSED scaffold-target-exists: {at(t, 'wd/Atlas')} " + "already exists and is not empty"), + "local-remote-dir-is-a-file": ( + "r", ("wd", "r"), + lambda t: f"{SCAFFOLD_FOLDER}: --local-remote-dir {at(t, 'r')} " + f"cannot be made: {at(t, 'r')} is a file, not a folder"), + "a-folder-above-the-work-dir-is-a-file": ( + "up", ("up/wd", "r"), + lambda t: f"{SCAFFOLD_FOLDER}: --work-dir {at(t, 'up/wd')} cannot " + f"be made: {at(t, 'up')} is a file, not a folder"), + "a-folder-above-the-remote-dir-is-a-file": ( + "up", ("wd", "up/r"), + lambda t: f"{SCAFFOLD_FOLDER}: --local-remote-dir {at(t, 'up/r')} " + f"cannot be made: {at(t, 'up')} is a file, not a folder"), +} + + +@pytest.mark.parametrize("case", list(SCAFFOLD_CASES)) +def test_a_scaffold_dry_run_refuses_what_its_real_run_refuses(tmp_path, case): + """L2-34, the scaffold: on main every dry run here exited 0 and every + real run but the non-empty tree's ended in a traceback, exit 1.""" + put, (work, remotes), said = SCAFFOLD_CASES[case] + touch(tmp_path / put) + assert_both_runs_refuse(tmp_path, scaffold_in(tmp_path, work, remotes), + said(tmp_path)) + + +#: `family.py init`'s three measured cases and four beside them, as +#: `SCAFFOLD_CASES` holds the scaffold's: `(what the test puts in the way, +#: the landing flags, the REFUSED line)`. On main 2989b4c each dry run exited +#: 0, and the real runs raised `FileExistsError` or `NotADirectoryError`, or +#: refused only the non-empty holder. +FAMILY_CASES = { + "family-folder-is-a-file": ( + "into/Orbit", (), + lambda t: f"{FAMILY_FOLDER}: the family folder {at(t, 'into/Orbit')} " + f"cannot be made: {at(t, 'into/Orbit')} is a file, not a " + "folder"), + "holder-is-a-file": ( + "into/Orbit/Orbit", (), + lambda t: f"REFUSED family-target-exists: " + f"{at(t, 'into/Orbit/Orbit')} already exists and is a " + "file, not a folder"), + "holder-nonempty": ( + "into/Orbit/Orbit/f", (), + lambda t: f"REFUSED family-target-exists: " + f"{at(t, 'into/Orbit/Orbit')} already exists and is not " + "empty"), + "into-is-a-file": ( + "into", (), + lambda t: f"{FAMILY_FOLDER}: the family folder {at(t, 'into/Orbit')} " + f"cannot be made: {at(t, 'into')} is a file, not a folder"), + "work-dir-is-a-file": ( + "wd", ("--work-dir", "wd"), + lambda t: f"{FAMILY_FOLDER}: --work-dir {at(t, 'wd')} cannot be " + f"made: {at(t, 'wd')} is a file, not a folder"), + "work-dir-holds-a-file-named-Orbit": ( + "wd/Orbit", ("--work-dir", "wd"), + lambda t: f"REFUSED family-target-exists: {at(t, 'wd/Orbit')} " + "already exists and is a file, not a folder"), + "local-remote-dir-is-a-file": ( + "r", (), + lambda t: f"{FAMILY_FOLDER}: --local-remote-dir {at(t, 'r')} cannot " + f"be made: {at(t, 'r')} is a file, not a folder"), +} + + +@pytest.mark.parametrize("case", list(FAMILY_CASES)) +def test_an_init_dry_run_refuses_what_its_real_run_refuses(tmp_path, case): + """L2-34, `init`: on main every dry run here exited 0, and every real + run but the non-empty holder's ended in a traceback, exit 1.""" + put, landing, said = FAMILY_CASES[case] + touch(tmp_path / put) + assert_both_runs_refuse(tmp_path, init_in(tmp_path, *landing), + said(tmp_path)) + + +@pytest.fixture +def unwritable(tmp_path): + """`tmp_path/ro`, a folder this user can read and enter but not write + in, made writable again for pytest to delete. Skipped where nothing is + refused to this user (root) and on Windows, whose folders have no such + mode.""" + if os.name == "nt" or os.geteuid() == 0: + pytest.skip("a folder's mode refuses nothing to this user here") + folder = tmp_path / "ro" + folder.mkdir() + folder.chmod(0o555) + yield folder + folder.chmod(0o755) + + +@pytest.mark.parametrize("tool", ["scaffold", "family"]) +def test_a_folder_that_cannot_be_written_in_is_refused_by_both_runs( + tmp_path, unwritable, tool): + """CAN BE MADE, NOT ONLY "IS NO FILE": a work directory under a folder + this user cannot write in failed the real run's `mkdir` with a + `PermissionError` traceback, and its dry run exited 0.""" + run = (scaffold_in(tmp_path, "ro/wd") if tool == "scaffold" + else init_in(tmp_path, "--work-dir", "ro/wd")) + folder = SCAFFOLD_FOLDER if tool == "scaffold" else FAMILY_FOLDER + assert_both_runs_refuse( + tmp_path, run, + f"{folder}: --work-dir {at(tmp_path, 'ro/wd')} cannot be made: " + f"{unwritable.resolve()} is a folder this user cannot write in") + + +# --- L2-35, and the scaffold's own: a refused real run leaves no folder ------- + +def test_a_real_init_refused_for_its_remote_leaves_no_family_folder( + tmp_path): + """L2-35: on main the real run made `into/Orbit/` and then refused + `family-remote-exists`, leaving it empty; #537's first line names it.""" + remote = live_bare(tmp_path / "r" / f"{HOLDER}.git") + real = assert_both_runs_refuse( + tmp_path, init_in(tmp_path), + f"REFUSED family-remote-exists: {remote.resolve()} already exists " + "and has commits") + assert "it HAS commits" in real.stderr + assert not (tmp_path / "into").exists() + + +def test_a_real_scaffold_refused_for_a_remote_leaves_no_work_dir(tmp_path): + """The same order in the scaffold: on main its real run made + `--work-dir` before it asked whether the remotes exist.""" + leg = live_bare(tmp_path / "r" / f"{PROJECT}-spec.git") + assert_both_runs_refuse( + tmp_path, scaffold_in(tmp_path), + f"REFUSED scaffold-remote-exists: {leg.resolve()} already exists " + "and has commits") + assert not (tmp_path / "wd").exists() + + +@pytest.mark.parametrize("tool", ["family", "scaffold"]) +def test_a_real_run_with_no_gh_leaves_no_work_dir(tmp_path, monkeypatch, + capsys, tool): + """`gh-not-found` from the real run, as from the dry run (#537), and now + before the work directory is made: on main the scaffold had made + `--work-dir` and `init` its parent before they asked `gh`.""" + module, argv, refusal, first = no_gh_run(tmp_path, tool, False) + asked = without_gh(monkeypatch) + assert module.main(argv) == 2 + assert refusal in capsys.readouterr().err + assert asked == [first] + assert not (tmp_path / "work").exists() + + +# --- the real run's own `mkdir`, when the question said yes ------------------- + +#: Where each real run makes a folder, as `(tool, the folder flags, what the +#: test puts in the way, how the refusal names the folder, the start of the +#: REFUSED line)`. The question is answered "yes" by a stand-in, as a folder +#: made or replaced between the question and the `mkdir` would answer it. +MKDIR_CASES = { + "scaffold-work-dir": ( + "scaffold", ("--work-dir", "wd", "--local-remote-dir", "r"), "wd", + f"{SCAFFOLD_FOLDER}: --work-dir"), + "scaffold-local-remote-dir": ( + "scaffold", ("--work-dir", "wd", "--local-remote-dir", "r"), "r", + f"{SCAFFOLD_FOLDER}: --local-remote-dir"), + "family-folder": ( + "family", ("--into", "into", "--local-remote-dir", "r"), "into/Orbit", + f"{FAMILY_FOLDER}: the family folder"), + "family-local-remote-dir": ( + "family", ("--into", "into", "--local-remote-dir", "r"), "r", + f"{FAMILY_FOLDER}: --local-remote-dir"), +} + + +@pytest.mark.parametrize("case", list(MKDIR_CASES)) +def test_a_folder_the_real_run_cannot_make_is_refused_not_raised( + tmp_path, monkeypatch, capsys, case): + """THE REAL RUN'S `mkdir` IS ANSWERED TOO: a folder that cannot be made + when the run makes it is the same refusal, in the system's words, and + never a `FileExistsError` traceback out of `main`. No remote is made.""" + tool, flags, put, said = MKDIR_CASES[case] + touch(tmp_path / put) + if tool == "scaffold": + module = load("parity_scaffold", SCAFFOLD) + argv = ["--org", ORG, "--project", PROJECT, "--elected-by", + "Test Human", "--elected-on", "2026-09-02"] + else: + module = load("parity_family", FAMILY) + argv = ["init", "--org", ORG, "--family", HOLDER, "--created-by", + "Test Human"] + argv += [str(tmp_path / value) if index % 2 else value + for index, value in enumerate(flags)] + monkeypatch.setattr(shape_materialize, "folder_cannot_be_made", + lambda path: None) + assert module.main(argv) == 2 + err = capsys.readouterr().err + assert (f"{said} {at(tmp_path, put)} cannot be made: File exists\n" + "Remediation: no remote was created.") in err, err + assert not list(tmp_path.rglob("*.git")) + + +# --- L2-36: only gh's not-found answer means "absent" ------------------------- + +#: A `gh` that answers `repo view` with `$STUB_GH_SAYS` on stderr, `%s` being +#: the repository asked about, and exits `$STUB_GH_EXIT`; anything else, a +#: `repo create` included, is refused with exit 97 and logged, so a run that +#: went on to create something is seen and never reaches GitHub. +STUB_GH = """#!/bin/sh +printf '%s\\n' "$*" >> "$STUB_GH_LOG" +case "$*" in + "repo view "*) + printf "$STUB_GH_SAYS\\n" "$3" >&2 + exit "$STUB_GH_EXIT" ;; +esac +echo "stub gh: not a repo view: $*" >&2 +exit 97 +""" + +#: GitHub's answer for a repository it does not have, as gh 2.x prints it +#: (`api.FetchRepository` and go-gh's `GraphQLError`), exit 1: the ONE +#: failure that means "absent". +NOT_FOUND = (1, "GraphQL: Could not resolve to a Repository with the name " + "'%s'. (repository)") + +#: Every other way `gh repo view` fails, as `(exit, what gh says)`: a bad +#: token, a rate limit, no network, gh's "authentication required" (exit 4), +#: and the not-found words under an exit that is not gh's not-found one. +FAILURES = { + "http-401": (1, "HTTP 401: Bad credentials " + "(https://api.github.com/graphql)\\nTry authenticating " + "with: gh auth login"), + "rate-limit": (1, "GraphQL: API rate limit exceeded for user ID 1."), + "no-network": (1, "error connecting to api.github.com\\ncheck your " + "internet connection or https://githubstatus.com"), + "auth-required": (4, "To get started with GitHub CLI, please run: gh " + "auth login"), + "not-found-words-exit-4": (4, NOT_FOUND[1]), +} + + +def stub_gh(tmp_path: Path, answer: tuple): + """`(env, calls)`: the stub first on PATH answering `repo view` with + `answer`, no real login reachable, and a reader of its log.""" + directory = tmp_path / "ghbin" + directory.mkdir() + shim = directory / "gh" + shim.write_bytes(STUB_GH.encode("utf-8")) + shim.chmod(shim.stat().st_mode | stat.S_IXUSR) + log = tmp_path / "gh.log" + code, says = answer + env = {"PATH": f"{directory}{os.pathsep}{os.environ['PATH']}", + "STUB_GH_LOG": str(log), "STUB_GH_SAYS": says, + "STUB_GH_EXIT": str(code), "GH_TOKEN": "bogus-w609", + "GH_CONFIG_DIR": str(tmp_path / "ghconfig")} + + def calls() -> list: + return (log.read_text(encoding="utf-8").splitlines() + if log.exists() else []) + return env, calls + + +def first_line(answer: tuple, repository: str) -> str: + """What the refusal quotes of gh's answer: its first line.""" + return answer[1].split("\\n")[0].replace("%s", repository) + + +def on_github(tmp_path: Path, tool: str, env: dict, *extra: str): + """A run of `tool` against GitHub (no `--local-remote-dir`) with `env`, + as `assert_both_runs_refuse` calls it.""" + def run(dry_run: bool): + if tool == "scaffold": + return scaffold(tmp_path, "--work-dir", str(tmp_path / "work"), + *extra, dry_run=dry_run, env=env) + return family_init(tmp_path, "--into", str(tmp_path / "into"), + *extra, dry_run=dry_run, env=env) + return run + + +def unreadable(repository: str, answer: tuple) -> str: + """The REFUSED line for a `gh repo view` failure that is not gh's + not-found answer.""" + return (f"REFUSED remote-unreadable: whether {repository} already exists " + "on GitHub cannot be told: `gh repo view " + f"{repository} --json name` exited {answer[0]}: " + f"{first_line(answer, repository)}") + + +@WINDOWS_SKIP # the `gh` stub is a `#!` script, which Windows does not run +@pytest.mark.parametrize("failure", list(FAILURES)) +@pytest.mark.parametrize("tool", ["scaffold", "family"]) +def test_a_gh_failure_that_is_not_not_found_is_refused_by_both_runs( + tmp_path, tool, failure): + """L2-36: on main a 401 read as "does not exist", so both dry runs + exited 0, the real scaffold failed at `gh repo create` and the real + `init` left an empty family folder. Now both runs refuse at the first + `repo view`, in the same words, and nothing is made or asked after.""" + answer = FAILURES[failure] + env, calls = stub_gh(tmp_path, answer) + repository = f"{ORG}/{PROJECT if tool == 'scaffold' else HOLDER}" + real = assert_both_runs_refuse(tmp_path, on_github(tmp_path, tool, env), + unreadable(repository, answer)) + assert ("Only gh's own answer that a repository does not exist (exit 1, " + "`Could not resolve to a Repository`)") in real.stderr + assert calls() == [f"repo view {repository} --json name"] * 2 + assert not (tmp_path / "work").exists() + assert not (tmp_path / "into").exists() + + +@WINDOWS_SKIP # the `gh` stub is a `#!` script, which Windows does not run +def test_a_reused_root_whose_existence_gh_cannot_tell_is_refused(tmp_path): + """`--reuse-empty-repo` reads the root's visibility first, and a failure + there is still "visibility unknown" (it only says which visibility the + legs inherit); the existence question that follows refuses it.""" + answer = FAILURES["http-401"] + env, calls = stub_gh(tmp_path, answer) + repository = f"{ORG}/{PROJECT}" + assert_both_runs_refuse( + tmp_path, on_github(tmp_path, "scaffold", env, "--reuse-empty-repo"), + unreadable(repository, answer)) + assert calls() == [f"repo view {repository} --json visibility", + f"repo view {repository} --json name"] * 2 + + +@WINDOWS_SKIP # the `gh` stub is a `#!` script, which Windows does not run +@pytest.mark.parametrize("tool", ["scaffold", "family"]) +def test_gh_not_found_still_means_absent_in_both_runs(tmp_path, tool): + """The control: gh's not-found answer is "absent", so the dry run plans + and exits 0 having asked each repository once, and the real run goes on + to `gh repo create`, which the stub refuses.""" + env, calls = stub_gh(tmp_path, NOT_FOUND) + run = on_github(tmp_path, tool, env) + names, created = (([PROJECT, f"{PROJECT}-spec", f"{PROJECT}-code"], + f"{PROJECT}-spec") if tool == "scaffold" + else ([HOLDER], HOLDER)) + views = [f"repo view {ORG}/{name} --json name" for name in names] + dry = run(dry_run=True) + assert dry.returncode == 0, dry.stdout + dry.stderr + assert DRY_RUN_DONE in dry.stdout + assert calls() == views + assert not (tmp_path / "work").exists() + assert not (tmp_path / "into").exists() + real = run(dry_run=False) + assert real.returncode == 2, real.stdout + real.stderr + assert "REFUSED a git or gh command failed" in real.stderr + assert calls()[:-1] == views * 2 + assert calls()[-1].startswith(f"repo create {ORG}/{created} ") + + +# --- one definition, both tools ----------------------------------------------- + +@pytest.mark.parametrize("tool", ["scaffold", "family"]) +def test_both_tools_ask_the_one_definition(tool): + module = (load("parity_scaffold_names", SCAFFOLD) if tool == "scaffold" + else load("parity_family_names", FAMILY)) + for name in ("refuse_unmakeable_folder", "make_folder", + "target_in_the_way", "refuse_unless_not_found"): + assert getattr(module, name) is getattr(shape_materialize, name), name + + +def probe(code: int, stderr: str, stdout: str = ""): + return subprocess.CompletedProcess( + ["gh", "repo", "view", "o/r", "--json", "name"], code, stdout, stderr) + + +def test_only_exit_1_with_gh_s_not_found_words_is_absent(): + shape_materialize.refuse_unless_not_found("o/r", probe( + 1, "GraphQL: Could not resolve to a Repository with the name 'o/r'. " + "(repository)\n")) + for code, said in ((1, "HTTP 401: Bad credentials\n"), (1, ""), + (4, "GraphQL: Could not resolve to a Repository\n"), + (1, "\n\n"), (2, "")): + with pytest.raises(Refusal) as refused: + shape_materialize.refuse_unless_not_found("o/r", + probe(code, said)) + assert refused.value.code == "remote-unreadable" + assert refused.value.detail == ( + "whether o/r already exists on GitHub cannot be told: `gh repo " + f"view o/r --json name` exited {code}" + + (f": {said.strip()}" if said.strip() else "")) + + +def test_the_refusal_quotes_gh_s_first_line_and_escapes_what_is_unprintable(): + with pytest.raises(Refusal) as refused: + shape_materialize.refuse_unless_not_found("o/r", probe( + 1, "\n HTTP 503: down\x1b[0m\nsecond line\n")) + assert refused.value.detail.endswith( + "exited 1: 'HTTP 503: down\\x1b[0m'") + with pytest.raises(Refusal) as refused: + shape_materialize.refuse_unless_not_found( + "o/r", probe(1, "", "only on stdout\n")) + assert refused.value.detail.endswith("exited 1: only on stdout") + + +def test_what_stands_where_a_folder_must_go(tmp_path): + """`folder_cannot_be_made`, by kind: nothing there and a folder are + fine; a file there or above it, and a link to nothing, are named.""" + ask = shape_materialize.folder_cannot_be_made + assert ask(tmp_path / "a" / "b") is None + assert ask(tmp_path) is None + touch(tmp_path / "f") + assert ask(tmp_path / "f") == f"{tmp_path / 'f'} is a file, not a folder" + assert ask(tmp_path / "f" / "g" / "h") == ( + f"{tmp_path / 'f'} is a file, not a folder") + if os.name != "nt": + (tmp_path / "nowhere").symlink_to(tmp_path / "gone") + assert ask(tmp_path / "nowhere" / "x") == ( + f"{tmp_path / 'nowhere'} cannot be used as a folder: No such " + "file or directory") + (tmp_path / "there").symlink_to(tmp_path / "a", True) + (tmp_path / "a").mkdir() + assert ask(tmp_path / "there" / "x") is None + + +def test_what_stands_where_a_working_tree_goes(tmp_path): + ask = shape_materialize.target_in_the_way + assert ask(tmp_path / "none") is None + (tmp_path / "empty").mkdir() + assert ask(tmp_path / "empty") is None + touch(tmp_path / "full" / "x") + assert ask(tmp_path / "full") == "is not empty" + touch(tmp_path / "file") + assert ask(tmp_path / "file") == "is a file, not a folder" + if os.name != "nt": + (tmp_path / "nowhere").symlink_to(tmp_path / "gone") + assert ask(tmp_path / "nowhere") == ( + "cannot be used as a folder: No such file or directory") + + +def test_make_folder_names_the_folder_and_the_system_s_reason(tmp_path): + touch(tmp_path / "f") + with pytest.raises(Refusal) as refused: + shape_materialize.make_folder("x-code", "--some-dir", + tmp_path / "f" / "g") + assert refused.value.code == "x-code" + assert refused.value.detail == ( + f"--some-dir {tmp_path / 'f' / 'g'} cannot be made: Not a directory") + shape_materialize.make_folder("x-code", "--d", tmp_path / "a" / "b") + assert (tmp_path / "a" / "b").is_dir() diff --git a/tests/test_scaffold_manifest_values.py b/tests/test_scaffold_manifest_values.py index 4fdd1918..042806c1 100644 --- a/tests/test_scaffold_manifest_values.py +++ b/tests/test_scaffold_manifest_values.py @@ -677,15 +677,22 @@ def test_the_names_a_refusal_shows_are_told_apart_by_their_tails(): def scaffold_against_a_fake_gh(tmp_path: Path, flag: str): """A scaffold with no `--local-remote-dir`, so it goes to the forge, with - a fake `gh` first on PATH that logs each call and answers no. Returns the - run and the log's path, which exists only if `gh` was asked.""" + a fake `gh` first on PATH that logs each call and answers no: `repo + view` as GitHub answers for a repository it does not have, the one + failure the scaffold reads as "absent" (#609), and anything else with a + failure of its own. Returns the run and the log's path, which exists + only if `gh` was asked.""" bin_dir, log = tmp_path / "bin", tmp_path / "gh.log" bin_dir.mkdir() shim = bin_dir / "gh" shim.write_text(textwrap.dedent("""\ #!/bin/sh echo "$@" >> "$FAKE_GH_LOG" - echo "fake gh: no network here" >&2 + case "$1 $2" in + "repo view") echo "GraphQL: Could not resolve to a Repository" \\ + "with the name '$3'. (repository)" >&2 ;; + *) echo "fake gh: no network here" >&2 ;; + esac exit 1 """), encoding="utf-8") shim.chmod(shim.stat().st_mode | stat.S_IXUSR) From 60e16019b316840936882239425de242b53fabc9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 22:34:30 +0000 Subject: [PATCH 2/4] scaffold-project.py and family.py: an existing EMPTY working tree or holder this user cannot write in is refused `scaffold-target-exists` / `family-target-exists`, by the dry run and the real run, before any remote is made, so neither exits 0 and then dies in a `PermissionError` traceback after the bare remotes exist (L2-60, #609) #459's review of #622 (L2-60, rv609's P3): `target_in_the_way` asked only whether the target held anything, so an empty folder at mode 555 passed both tools' questions. With local remotes the dry run exited 0 with `--dry-run: nothing was created.`, and the real run made the bare remotes and raised `PermissionError: [Errno 13] ... README.md` from the first write into the tree (`_build_assembly_root`, `_create_holder_worktree`). Against GitHub that leaves created repositories behind. `target_in_the_way` now asks, after the emptiness check, the question `folder_cannot_be_made` asks of a folder to make: `os.access(W_OK | X_OK)`, answered `is a folder this user cannot write in` (one constant, shared with `folder_cannot_be_made`). A folder that holds anything is still `is not empty`, whatever its mode. Both tools already asked `target_in_the_way` for every tree and the holder before the remote question, so no caller changed. Tests (tests/test_dry_run_parity.py), red on the merge of main 396c0ff4 before this change: one test per tool, mode (`--dry-run` or real) and permission (no `w`, no `x`), 8 in all, and the in-process `target_in_the_way` test; each is skipped for root and on Windows as the `unwritable` fixture is. L2-67 (#461's review of #622, rv609's nit 2): the two tests that spelled POSIX `strerror` (`File exists`, `Not a directory`) now build the expected text from the exception `mkdir` raises, `mkdir_says`, so they hold where the system words it otherwise. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 --- scripts/shape_materialize.py | 19 +++++- tests/test_dry_run_parity.py | 111 +++++++++++++++++++++++++++++++---- 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index e7c43ba1..b40bc4cf 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -1305,6 +1305,11 @@ def refuse_bad_branch(refusal: str, what: str, value) -> None: "Move what stands in the way aside, or pass a path where a folder can be " "made. There is no --force.") +#: What a folder this user cannot write in is called, by +#: `folder_cannot_be_made` (a folder to make) and `target_in_the_way` (an +#: empty working tree) alike. +CANNOT_WRITE_IN = "is a folder this user cannot write in" + #: Said when the real run's own `mkdir` fails, after the question said yes: #: the run has made folders by then, and no remote. MKDIR_REMEDIATION = ( @@ -1349,7 +1354,7 @@ def folder_cannot_be_made(path: Path) -> str | None: if why: return f"{here} {why}" if not os.access(here, os.W_OK | os.X_OK): - return f"{here} is a folder this user cannot write in" + return f"{here} {CANNOT_WRITE_IN}" return None return None @@ -1390,7 +1395,15 @@ def target_in_the_way(target: Path) -> str | None: A FILE THERE was a `NotADirectoryError` out of `target.iterdir()` in the real run, after its dry run exited 0. It is said here, and so is a folder that holds anything, which both tools always refused, and one - this user cannot list.""" + this user cannot list. + + AN EMPTY FOLDER THIS USER CANNOT WRITE IN IS SAID TOO (L2-60), last: the + emptiness check passed it, the dry run exited 0, and the real run made + its remotes and then died in a `PermissionError` traceback at the first + file it wrote there. It asks the question `folder_cannot_be_made` asks + of a folder to make, `os.access(W_OK | X_OK)`: a folder with no `w` + takes no file and one with no `x` cannot be entered, and a folder that + holds something is still `is not empty`, whatever its mode.""" if not os.path.lexists(target): return None why = _not_a_folder(target) @@ -1401,6 +1414,8 @@ def target_in_the_way(target: Path) -> str | None: return "is not empty" except OSError as exc: return f"cannot be read: {exc.strerror or exc}" + if not os.access(target, os.W_OK | os.X_OK): + return CANNOT_WRITE_IN return None diff --git a/tests/test_dry_run_parity.py b/tests/test_dry_run_parity.py index 76c4fbc9..ae7e0d4e 100644 --- a/tests/test_dry_run_parity.py +++ b/tests/test_dry_run_parity.py @@ -16,6 +16,11 @@ empty family folder. - L2-35. A real `init` made `//` before it asked whether the holder's remote exists, so a refusal left that folder empty behind it. +- L2-60 (#459's review of #622). An EXISTING EMPTY working tree or holder this + user cannot write in was not asked: the dry run exited 0, and the real run + made the remotes and then died with a `PermissionError` traceback. +- L2-67 (#461's review of #622). Two tests spelled POSIX `strerror` text; + `mkdir_says` asks the system for its own words. THE CURE. One question, asked by both runs before anything is made: every folder the run makes (`shape_materialize.folder_cannot_be_made`), each @@ -61,6 +66,17 @@ def touch(path: Path) -> None: path.write_bytes(b"somebody's\n") +def mkdir_says(path: Path) -> str: + """The system's own words for `path.mkdir(parents=True, exist_ok=True)` + failing, as `make_folder` quotes them: its `strerror`. POSIX says `File + exists` and `Not a directory`; Windows says `Cannot create a file when + that file already exists`. A test asks the system, and spells neither. + The call fails, so it makes nothing (L2-67).""" + with pytest.raises(OSError) as caught: + path.mkdir(parents=True, exist_ok=True) + return caught.value.strerror or str(caught.value) + + def snapshot(root: Path, leave_out=("ghbin", "gh.log")) -> dict: """Every path under `root` and what it is (a folder, a file's bytes, a link's target), so a test sees whether a run made, changed or removed @@ -253,18 +269,35 @@ def test_an_init_dry_run_refuses_what_its_real_run_refuses(tmp_path, case): @pytest.fixture -def unwritable(tmp_path): - """`tmp_path/ro`, a folder this user can read and enter but not write - in, made writable again for pytest to delete. Skipped where nothing is - refused to this user (root) and on Windows, whose folders have no such - mode.""" +def unwritable_folder(tmp_path): + """A maker of folders this user cannot write in, where the test says: + `make(relative, mode, holding=None)` makes `tmp_path/relative` (every + folder above it writable), puts the file `holding` in it if told to, + sets `mode` and returns it. Each is made writable again for pytest to + delete. Skipped where nothing is refused to this user (root) and on + Windows, whose folders have no such mode.""" if os.name == "nt" or os.geteuid() == 0: pytest.skip("a folder's mode refuses nothing to this user here") - folder = tmp_path / "ro" - folder.mkdir() - folder.chmod(0o555) - yield folder - folder.chmod(0o755) + made: list = [] + + def make(relative: str, mode: int, holding: str | None = None) -> Path: + folder = tmp_path / relative + folder.mkdir(parents=True) + if holding: + touch(folder / holding) + folder.chmod(mode) + made.append(folder) + return folder + yield make + for folder in made: + folder.chmod(0o755) + + +@pytest.fixture +def unwritable(unwritable_folder): + """`tmp_path/ro`, a folder this user can read and enter but not write + in.""" + return unwritable_folder("ro", 0o555) @pytest.mark.parametrize("tool", ["scaffold", "family"]) @@ -282,6 +315,58 @@ def test_a_folder_that_cannot_be_written_in_is_refused_by_both_runs( f"{unwritable.resolve()} is a folder this user cannot write in") +# --- L2-60: an EMPTY working tree this user cannot write in ------------------ + +#: A folder this user cannot write in, two ways: with no `w` (nothing may be +#: made in it) and with no `x` (nothing may be entered, so nothing made in +#: it either, though it still lists as empty). `os.access` is asked for +#: both, and each mode kills the question that asks for only one. +CANNOT_WRITE = {"no-w": 0o555, "no-x": 0o600} + +#: Where each tool writes a working tree, as `(the tree's path under the +#: test's directory, how a run lands there, the code it refuses an occupied +#: tree by)`. +TREE_AT = { + "scaffold": ("wd/Atlas", scaffold_in, "scaffold-target-exists"), + "family": ("into/Orbit/Orbit", init_in, "family-target-exists"), +} + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real-run"]) +@pytest.mark.parametrize("how", list(CANNOT_WRITE)) +@pytest.mark.parametrize("tool", list(TREE_AT)) +def test_an_empty_working_tree_this_user_cannot_write_in_is_refused_by_name( + tmp_path, unwritable_folder, tool, how, dry_run): + """L2-60, one run each: an existing EMPTY working tree (the scaffold's + root, `init`'s holder) that this user cannot write in is refused by name + before any remote is made, by the dry run and by the real run, which no + longer dies in a `PermissionError` traceback once the bare remote + exists. On main 396c0ff4 every dry run here exited 0, and every real + run made the bare remote and then raised.""" + put, lands, code = TREE_AT[tool] + tree = unwritable_folder(put, CANNOT_WRITE[how]) + scratch_env(tmp_path) + before = snapshot(tmp_path) + result = lands(tmp_path)(dry_run=dry_run) + assert_refused(result, f"REFUSED {code}: {tree.resolve()} already exists " + "and is a folder this user cannot write in") + assert snapshot(tmp_path) == before, "the run made something" + assert not list(tmp_path.rglob("*.git")), "a remote was made" + + +def test_a_working_tree_this_user_cannot_write_in_is_named_last( + unwritable_folder): + """`target_in_the_way`: an EMPTY folder this user cannot write in is + named, and a folder that holds anything is still `is not empty` + whatever its mode, the sentence both tools always gave.""" + ask = shape_materialize.target_in_the_way + for how, mode in CANNOT_WRITE.items(): + assert (ask(unwritable_folder(f"empty-{how}", mode)) + == "is a folder this user cannot write in") + assert (ask(unwritable_folder(f"full-{how}", mode, holding="f")) + == "is not empty") + + # --- L2-35, and the scaffold's own: a refused real run leaves no folder ------- def test_a_real_init_refused_for_its_remote_leaves_no_family_folder( @@ -366,7 +451,8 @@ def test_a_folder_the_real_run_cannot_make_is_refused_not_raised( lambda path: None) assert module.main(argv) == 2 err = capsys.readouterr().err - assert (f"{said} {at(tmp_path, put)} cannot be made: File exists\n" + assert (f"{said} {at(tmp_path, put)} cannot be made: " + f"{mkdir_says(tmp_path / put)}\n" "Remediation: no remote was created.") in err, err assert not list(tmp_path.rglob("*.git")) @@ -605,6 +691,7 @@ def test_make_folder_names_the_folder_and_the_system_s_reason(tmp_path): tmp_path / "f" / "g") assert refused.value.code == "x-code" assert refused.value.detail == ( - f"--some-dir {tmp_path / 'f' / 'g'} cannot be made: Not a directory") + f"--some-dir {tmp_path / 'f' / 'g'} cannot be made: " + f"{mkdir_says(tmp_path / 'f' / 'g')}") shape_materialize.make_folder("x-code", "--d", tmp_path / "a" / "b") assert (tmp_path / "a" / "b").is_dir() From 5263d788eae35a06ef0b91d5c46f10d8b3c95f72 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 23:02:27 +0000 Subject: [PATCH 3/4] scaffold-project.py and family.py: the refusal of an EMPTY working tree this user cannot write in says to give this user write and search permission on that folder or choose another `--work-dir` (`--into` for an `init` landed by it), so it no longer tells a person to "choose an empty --work-dir" about a folder that is empty (L2-60, #609) The previous commit (L2-60) made `target_in_the_way` answer "is a folder this user cannot write in" for an empty tree, but both callers kept their one remediation for it: "choose an empty --work-dir. There is no --force: re-running over a live tree is not a scaffold." That is about a tree that holds something, and wrong for an empty folder whose only fault is its permissions. `target_remediation` in scripts/shape_materialize.py is now the one place that chooses, by what `target_in_the_way` said: for `CANNOT_WRITE_IN` it says "nothing was created, and `--dry-run` refuses the same. Give this user write and search permission on that folder, or choose another , and run this again", and for every other answer it returns the tool's own sentence unchanged. The scaffold passes `--work-dir`; `init` passes the flag the person landed by, `--work-dir` where there is no family folder and `--into` otherwise, so `_refuse_a_taken_holder` takes the folder. The finding codes (`scaffold-target-exists`, `family-target-exists`) are unchanged. tests/test_dry_run_parity.py pins the new sentence on the unwritable row of each tool (the eight run tests) and under `init --work-dir`, pins that a tree holding a file or anything else keeps the old sentence exactly, and checks the chooser itself. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 --- scaffold-project.py | 9 ++-- scripts/family.py | 22 ++++++---- scripts/shape_materialize.py | 17 ++++++++ tests/test_dry_run_parity.py | 79 +++++++++++++++++++++++++++++++++--- 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/scaffold-project.py b/scaffold-project.py index 707e8934..ec00f04c 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -122,7 +122,7 @@ # not-found answer as the one failure of `gh repo view` that means absent. from shape_materialize import ( # noqa: E402 make_folder, refuse_unless_not_found, refuse_unmakeable_folder, - target_in_the_way, + target_in_the_way, target_remediation, ) #: How a refusal names each value the manifest records (#258, #261). The leg @@ -1183,8 +1183,11 @@ def _refuse_existing_trees(work_root: Path, names: dict) -> None: raise Refusal( "scaffold-target-exists", f"{target} already exists and {why}", - "Remediation: choose an empty --work-dir. There is no --force: " - "re-running over a live tree is not a scaffold.", + target_remediation( + why, + "Remediation: choose an empty --work-dir. There is no " + "--force: re-running over a live tree is not a scaffold.", + "--work-dir"), ) diff --git a/scripts/family.py b/scripts/family.py index f9354f2c..9a59f627 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -178,7 +178,7 @@ force_stage, inherited_child_environment, make_folder, materialize_family_root, refuse_bad_branch, refuse_unless_not_found, refuse_unmakeable_folder, run, stage_written, target_in_the_way, - trailer_line, write_lf, + target_remediation, trailer_line, write_lf, ) # What `init` asks of the holder's own reader and validator before it creates # anything (#307), imported as its own unit and not added to the lists above. @@ -1251,17 +1251,23 @@ def _landing_folder(folder: Path | None, holder: Path) -> tuple[Path, str]: return folder, "the family folder" -def _refuse_a_taken_holder(holder: Path) -> None: +def _refuse_a_taken_holder(folder: Path | None, holder: Path) -> None: """`family-target-exists`: something already stands where the holder goes, a folder that holds anything or, since #609, a file, which - `iterdir()` met with a `NotADirectoryError` traceback.""" + `iterdir()` met with a `NotADirectoryError` traceback, or an empty folder + this user cannot write in (L2-60), whose remediation names the flag the + person landed by: `--work-dir` where there is no family folder, `--into` + otherwise.""" why = target_in_the_way(holder) if why: raise Refusal( "family-target-exists", f"{holder} already exists and {why}", - "Remediation: choose another --into (or an empty --work-dir); if " - "that IS the holder, it is already a family and `family.py add` " - "is what grows it. There is no --force.") + target_remediation( + why, + "Remediation: choose another --into (or an empty " + "--work-dir); if that IS the holder, it is already a family " + "and `family.py add` is what grows it. There is no --force.", + "--work-dir" if folder is None else "--into")) def _refuse_unmakeable_landing(args, folder: Path | None, holder: Path, @@ -1280,7 +1286,7 @@ def _refuse_unmakeable_landing(args, folder: Path | None, holder: Path, """ landing, named = _landing_folder(folder, holder) refuse_unmakeable_folder(FOLDER_REFUSAL, named, landing) - _refuse_a_taken_holder(holder) + _refuse_a_taken_holder(folder, holder) if local: refuse_unmakeable_folder(FOLDER_REFUSAL, "--local-remote-dir", args.local_remote_dir.resolve()) @@ -1299,7 +1305,7 @@ def _prepare_landing_dir(folder: Path | None, holder: Path) -> Path: # landed in, is exactly the case this is supposed to join. landing, named = _landing_folder(folder, holder) make_folder(FOLDER_REFUSAL, named, landing) - _refuse_a_taken_holder(holder) + _refuse_a_taken_holder(folder, holder) return holder diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index b40bc4cf..1321de01 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -1419,6 +1419,23 @@ def target_in_the_way(target: Path) -> str | None: return None +def target_remediation(why: str, holds: str, choose: str) -> str: + """The remediation of a `-target-exists` refusal, chosen by what + `target_in_the_way` said, in the one place both tools ask it (#609). + + `holds` is the tool's own sentence for a tree that HOLDS something, a + file included: choose an empty folder, there is no --force. It is wrong + for an EMPTY folder whose only fault is its permissions + (`CANNOT_WRITE_IN`, L2-60), which is told to be given write and search + permission for this user, or to be replaced by another folder, named by + `choose`, the flag the person met it by.""" + if why != CANNOT_WRITE_IN: + return holds + return ("Remediation: nothing was created, and `--dry-run` refuses the " + "same. Give this user write and search permission on that " + f"folder, or choose another {choose}, and run this again.") + + # --------------------------------------------------------------------------- # What a run asks before its first write: whether a repository exists (#609) # --------------------------------------------------------------------------- diff --git a/tests/test_dry_run_parity.py b/tests/test_dry_run_parity.py index ae7e0d4e..0aaf9745 100644 --- a/tests/test_dry_run_parity.py +++ b/tests/test_dry_run_parity.py @@ -118,6 +118,12 @@ def refused_line(result) -> str: if line.startswith("REFUSED ")), "") +def remediation_line(result) -> str: + """The `Remediation:` line a refusal ended on.""" + return next((line for line in result.stderr.splitlines() + if line.startswith("Remediation:")), "") + + def assert_refused(result, said: str) -> None: """Exit 2 with the refusal `said`, its whole line, and no traceback.""" assert result.returncode == 2, result.stdout + result.stderr @@ -325,10 +331,31 @@ def test_a_folder_that_cannot_be_written_in_is_refused_by_both_runs( #: Where each tool writes a working tree, as `(the tree's path under the #: test's directory, how a run lands there, the code it refuses an occupied -#: tree by)`. +#: tree by, the flag the person met the folder by)`. TREE_AT = { - "scaffold": ("wd/Atlas", scaffold_in, "scaffold-target-exists"), - "family": ("into/Orbit/Orbit", init_in, "family-target-exists"), + "scaffold": ("wd/Atlas", scaffold_in, "scaffold-target-exists", + "--work-dir"), + "family": ("into/Orbit/Orbit", init_in, "family-target-exists", "--into"), +} + + +def unwritable_remediation(flag: str) -> str: + """What the refusal of an EMPTY working tree this user cannot write in + says to do: not "choose an empty folder", which is about a tree that + holds something.""" + return ("Remediation: nothing was created, and `--dry-run` refuses the " + "same. Give this user write and search permission on that " + f"folder, or choose another {flag}, and run this again.") + + +#: What each tool says to do about a tree that HOLDS something, as it always +#: said it; the empty unwritable tree no longer says it. +HOLDS_REMEDIATION = { + "scaffold": "Remediation: choose an empty --work-dir. There is no " + "--force: re-running over a live tree is not a scaffold.", + "family": "Remediation: choose another --into (or an empty --work-dir); " + "if that IS the holder, it is already a family and `family.py " + "add` is what grows it. There is no --force.", } @@ -343,17 +370,47 @@ def test_an_empty_working_tree_this_user_cannot_write_in_is_refused_by_name( longer dies in a `PermissionError` traceback once the bare remote exists. On main 396c0ff4 every dry run here exited 0, and every real run made the bare remote and then raised.""" - put, lands, code = TREE_AT[tool] + put, lands, code, flag = TREE_AT[tool] tree = unwritable_folder(put, CANNOT_WRITE[how]) scratch_env(tmp_path) before = snapshot(tmp_path) result = lands(tmp_path)(dry_run=dry_run) assert_refused(result, f"REFUSED {code}: {tree.resolve()} already exists " "and is a folder this user cannot write in") + assert remediation_line(result) == unwritable_remediation(flag) assert snapshot(tmp_path) == before, "the run made something" assert not list(tmp_path.rglob("*.git")), "a remote was made" +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real-run"]) +def test_an_unwritable_holder_under_work_dir_says_to_choose_another_work_dir( + tmp_path, unwritable_folder, dry_run): + """The remediation names the flag the person landed by: with + `--work-dir` there is no family folder, so `--into` would not move the + holder it names.""" + tree = unwritable_folder("wd/Orbit", CANNOT_WRITE["no-w"]) + result = init_in(tmp_path, "--work-dir", "wd")(dry_run=dry_run) + assert_refused(result, f"REFUSED family-target-exists: {tree.resolve()} " + "already exists and is a folder this user cannot " + "write in") + assert remediation_line(result) == unwritable_remediation("--work-dir") + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real-run"]) +@pytest.mark.parametrize("held", ["a-file", "a-nonempty-folder"]) +@pytest.mark.parametrize("tool", list(TREE_AT)) +def test_a_working_tree_that_holds_something_keeps_its_remediation( + tmp_path, tool, held, dry_run): + """A tree that holds something, a file included, is told what both tools + always told it, "choose an empty folder; there is no --force", and not + the permissions sentence that fits an empty one.""" + put, lands, code, _ = TREE_AT[tool] + touch(tmp_path / put if held == "a-file" else tmp_path / put / "f") + result = lands(tmp_path)(dry_run=dry_run) + assert refused_line(result).startswith(f"REFUSED {code}: ") + assert remediation_line(result) == HOLDS_REMEDIATION[tool] + + def test_a_working_tree_this_user_cannot_write_in_is_named_last( unwritable_folder): """`target_in_the_way`: an EMPTY folder this user cannot write in is @@ -367,6 +424,17 @@ def test_a_working_tree_this_user_cannot_write_in_is_named_last( == "is not empty") +def test_the_remediation_is_chosen_by_what_stands_in_the_way(): + """`target_remediation`: the permissions sentence for the one answer + that says so, and the tool's own sentence, unchanged, for every other.""" + pick = shape_materialize.target_remediation + assert pick(shape_materialize.CANNOT_WRITE_IN, "holds", "--x") == ( + unwritable_remediation("--x")) + for why in ("is not empty", "is a file, not a folder", + "cannot be read: Permission denied"): + assert pick(why, "holds", "--x") == "holds" + + # --- L2-35, and the scaffold's own: a refused real run leaves no folder ------- def test_a_real_init_refused_for_its_remote_leaves_no_family_folder( @@ -611,7 +679,8 @@ def test_both_tools_ask_the_one_definition(tool): module = (load("parity_scaffold_names", SCAFFOLD) if tool == "scaffold" else load("parity_family_names", FAMILY)) for name in ("refuse_unmakeable_folder", "make_folder", - "target_in_the_way", "refuse_unless_not_found"): + "target_in_the_way", "target_remediation", + "refuse_unless_not_found"): assert getattr(module, name) is getattr(shape_materialize, name), name From 227bc3b01c088a8bc94ca82670ba8aa8065abc27 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 23:29:42 +0000 Subject: [PATCH 4/4] family.py and shape_materialize.py: `init --into ` refuses a holder this user cannot search by name instead of raising `PermissionError`, and a folder a run makes that is only unwritable is told to be given write and search permission or replaced by another `--work-dir`, `--local-remote-dir` or `--into`, so neither ends in a traceback or tells a person to move aside what is not in the way (#609) rv622r's re-verify of the L2-60 change found two things. Problem 1. With `--into` named for the family (or the working directory named for it), `Orbit/Orbit` is the holder, and `_landing` asked `(into / family / MANIFEST).is_file()` of it. For a holder at mode 600, with no search bit, `Path.is_file()` raised `PermissionError` (3.12 ignores only ENOENT, ENOTDIR, EBADF and ELOOP), so both runs exited 1 with a traceback before `_refuse_a_taken_holder` could name the folder; with the search bit (mode 555) it was already refused by name. It is the same on main 396c0ff4 (#79). `_landing` now asks `os.path.isfile`, which answers False, as `_standard_checkout` beside it already does. Every other `.is_file()` or `.exists()` on that path is already guarded: the folder and tree questions use `os.path.lexists` and `stat`/`iterdir` inside `try`, and the two `bare.exists()` calls (`_local_remote_is_empty` in both tools) come after the `--local-remote-dir` question, which refuses a folder with no `w` or no `x` first (probed at modes 600 and 000, the folder itself and a path below it). Problem 2. `FOLDER_REMEDIATION` says "Move what stands in the way aside", which is wrong for an existing folder (`--work-dir`, `--local-remote-dir`, the family folder, or one above it) whose only fault is its permissions. `refuse_unmakeable_folder` now takes the flag to name (`--into` for the family folder, which `named` spells "the family folder") and chooses by what `folder_cannot_be_made` said: for `CANNOT_WRITE_IN` it says what the trees' `target_remediation` says, from one `_unwritable_remediation`; every other answer keeps `FOLDER_REMEDIATION` exactly. `family.py` names the flag by `_landing_flag`, also used for the holder's refusal. tests/test_dry_run_parity.py: the `Orbit/Orbit` layout at modes 555 and 600, both runs; one row per flag (scaffold `--work-dir` and `--local-remote-dir`, `init` `--into`, `--work-dir` and `--local-remote-dir`), both runs; the ancestor case; eight files-in-the-way cases that keep the old sentence; the chooser. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 --- scripts/family.py | 17 ++++- scripts/shape_materialize.py | 31 ++++++-- tests/test_dry_run_parity.py | 134 ++++++++++++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 11 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index 9a59f627..11afa969 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -1068,7 +1068,11 @@ def _landing(args, family: str) -> tuple[Path | None, Path]: if args.work_dir is not None: return None, args.work_dir.expanduser().resolve() / family into = (args.into or Path.cwd()).expanduser().resolve() - if into.name == family and (into / family / MANIFEST).is_file(): + # `os.path.isfile`, which answers False for a holder this user cannot + # search: `Path.is_file()` raised `PermissionError` there, a traceback + # before the question about the holder (`_refuse_a_taken_holder`) that + # names it (#609's rule: a refusal by name, never a traceback). + if into.name == family and os.path.isfile(into / family / MANIFEST): raise Refusal( "family-holder-already-here", f"{into} is already the {family} family folder and its holder " @@ -1251,6 +1255,12 @@ def _landing_folder(folder: Path | None, holder: Path) -> tuple[Path, str]: return folder, "the family folder" +def _landing_flag(folder: Path | None) -> str: + """The flag the person landed `init` by: `--work-dir` where there is no + family folder, `--into` otherwise.""" + return "--work-dir" if folder is None else "--into" + + def _refuse_a_taken_holder(folder: Path | None, holder: Path) -> None: """`family-target-exists`: something already stands where the holder goes, a folder that holds anything or, since #609, a file, which @@ -1267,7 +1277,7 @@ def _refuse_a_taken_holder(folder: Path | None, holder: Path) -> None: "Remediation: choose another --into (or an empty " "--work-dir); if that IS the holder, it is already a family " "and `family.py add` is what grows it. There is no --force.", - "--work-dir" if folder is None else "--into")) + _landing_flag(folder))) def _refuse_unmakeable_landing(args, folder: Path | None, holder: Path, @@ -1285,7 +1295,8 @@ def _refuse_unmakeable_landing(args, folder: Path | None, holder: Path, the system's words. """ landing, named = _landing_folder(folder, holder) - refuse_unmakeable_folder(FOLDER_REFUSAL, named, landing) + refuse_unmakeable_folder(FOLDER_REFUSAL, named, landing, + _landing_flag(folder)) _refuse_a_taken_holder(folder, holder) if local: refuse_unmakeable_folder(FOLDER_REFUSAL, "--local-remote-dir", diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 1321de01..351ab603 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -1359,17 +1359,36 @@ def folder_cannot_be_made(path: Path) -> str | None: return None -def refuse_unmakeable_folder(refusal: str, named: str, path: Path) -> None: +def _unwritable_remediation(choose: str) -> str: + """What a refusal of a folder this user cannot write in says to do, for + a folder to make and for a working tree alike: give this user write and + search permission on it, or choose another folder by the flag `choose`. + """ + return ("Remediation: nothing was created, and `--dry-run` refuses the " + "same. Give this user write and search permission on that " + f"folder, or choose another {choose}, and run this again.") + + +def refuse_unmakeable_folder(refusal: str, named: str, path: Path, + choose: str | None = None) -> None: """Raise `Refusal(refusal)` when `folder_cannot_be_made(path)` says why. `refusal` is the caller's own code (`scaffold-folder-cannot-be-made`) and `named` how the person met the folder (`--work-dir`, `the family folder`), so each tool refuses in its own voice. Asked by the dry run - and the real run alike, before anything is made.""" + and the real run alike, before anything is made. + + A FOLDER THIS USER CANNOT WRITE IN (`CANNOT_WRITE_IN`, the folder itself + or one above it) is not told to "move what stands in the way aside": it + is told what `_unwritable_remediation` says, naming the flag `choose`, + which is `named` unless it is no flag (`the family folder`, met by + `--into`). Every other answer keeps `FOLDER_REMEDIATION`.""" why = folder_cannot_be_made(path) if why: - raise Refusal(refusal, f"{named} {path} cannot be made: {why}", - FOLDER_REMEDIATION) + raise Refusal( + refusal, f"{named} {path} cannot be made: {why}", + _unwritable_remediation(choose or named) + if why.endswith(CANNOT_WRITE_IN) else FOLDER_REMEDIATION) def make_folder(refusal: str, named: str, path: Path) -> None: @@ -1431,9 +1450,7 @@ def target_remediation(why: str, holds: str, choose: str) -> str: `choose`, the flag the person met it by.""" if why != CANNOT_WRITE_IN: return holds - return ("Remediation: nothing was created, and `--dry-run` refuses the " - "same. Give this user write and search permission on that " - f"folder, or choose another {choose}, and run this again.") + return _unwritable_remediation(choose) # --------------------------------------------------------------------------- diff --git a/tests/test_dry_run_parity.py b/tests/test_dry_run_parity.py index 0aaf9745..31170cb6 100644 --- a/tests/test_dry_run_parity.py +++ b/tests/test_dry_run_parity.py @@ -21,6 +21,10 @@ made the remotes and then died with a `PermissionError` traceback. - L2-67 (#461's review of #622). Two tests spelled POSIX `strerror` text; `mkdir_says` asks the system for its own words. +- rv622r's two problems with the L2-60 change: `init` with `--into` named for + the family raised `PermissionError` for a holder with no search bit before + any question was asked, and a folder that is only unwritable was told to + "move what stands in the way aside". THE CURE. One question, asked by both runs before anything is made: every folder the run makes (`shape_materialize.folder_cannot_be_made`), each @@ -315,10 +319,116 @@ def test_a_folder_that_cannot_be_written_in_is_refused_by_both_runs( run = (scaffold_in(tmp_path, "ro/wd") if tool == "scaffold" else init_in(tmp_path, "--work-dir", "ro/wd")) folder = SCAFFOLD_FOLDER if tool == "scaffold" else FAMILY_FOLDER - assert_both_runs_refuse( + real = assert_both_runs_refuse( tmp_path, run, f"{folder}: --work-dir {at(tmp_path, 'ro/wd')} cannot be made: " f"{unwritable.resolve()} is a folder this user cannot write in") + assert remediation_line(real) == unwritable_remediation("--work-dir") + + +#: Where a folder a run makes can be one this user cannot write in, as `(the +#: folder the test makes empty and unwritable, how a run lands, the REFUSED +#: line both runs end on, the flag the remediation names)`: one row per +#: flag, in each tool that has it. +CANNOT_WRITE_TEXT = "is a folder this user cannot write in" +UNWRITABLE_FOLDER_AT = { + "scaffold-work-dir": ( + "wd", lambda t: scaffold_in(t), + lambda t: f"{SCAFFOLD_FOLDER}: --work-dir {at(t, 'wd')} cannot be " + f"made: {at(t, 'wd')} {CANNOT_WRITE_TEXT}", "--work-dir"), + "scaffold-local-remote-dir": ( + "r", lambda t: scaffold_in(t), + lambda t: f"{SCAFFOLD_FOLDER}: --local-remote-dir {at(t, 'r')} " + f"cannot be made: {at(t, 'r')} {CANNOT_WRITE_TEXT}", + "--local-remote-dir"), + "family-into": ( + "into/Orbit", lambda t: init_in(t), + lambda t: f"{FAMILY_FOLDER}: the family folder {at(t, 'into/Orbit')} " + f"cannot be made: {at(t, 'into/Orbit')} " + f"{CANNOT_WRITE_TEXT}", "--into"), + "family-work-dir": ( + "wd", lambda t: init_in(t, "--work-dir", "wd"), + lambda t: f"{FAMILY_FOLDER}: --work-dir {at(t, 'wd')} cannot be " + f"made: {at(t, 'wd')} {CANNOT_WRITE_TEXT}", "--work-dir"), + "family-local-remote-dir": ( + "r", lambda t: init_in(t), + lambda t: f"{FAMILY_FOLDER}: --local-remote-dir {at(t, 'r')} cannot " + f"be made: {at(t, 'r')} {CANNOT_WRITE_TEXT}", + "--local-remote-dir"), +} + +#: What every OTHER refusal of a folder says to do, as it always said it. +FOLDER_REMEDIATION = ( + "Remediation: nothing was created, and `--dry-run` refuses the same. " + "Move what stands in the way aside, or pass a path where a folder can be " + "made. There is no --force.") + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real-run"]) +@pytest.mark.parametrize("case", list(UNWRITABLE_FOLDER_AT)) +def test_an_unwritable_folder_a_run_makes_is_told_what_fits( + tmp_path, unwritable_folder, case, dry_run): + """rv622r's nit: an existing EMPTY `--work-dir`, `--local-remote-dir` or + family folder whose only fault is its permissions was told to "move what + stands in the way aside", nothing standing in the way. Each flag, in each + tool, now says to give this user write and search permission or to + choose another folder by that flag, and the run makes nothing.""" + put, lands, said, flag = UNWRITABLE_FOLDER_AT[case] + unwritable_folder(put, CANNOT_WRITE["no-w"]) + scratch_env(tmp_path) + before = snapshot(tmp_path) + result = lands(tmp_path)(dry_run=dry_run) + assert_refused(result, said(tmp_path)) + assert remediation_line(result) == unwritable_remediation(flag) + assert snapshot(tmp_path) == before, "the run made something" + + +@pytest.mark.parametrize("tool, case", [ + ("scaffold", "work-dir-is-a-file"), + ("scaffold", "local-remote-dir-is-a-file"), + ("scaffold", "a-folder-above-the-work-dir-is-a-file"), + ("scaffold", "a-folder-above-the-remote-dir-is-a-file"), + ("family", "family-folder-is-a-file"), + ("family", "into-is-a-file"), + ("family", "work-dir-is-a-file"), + ("family", "local-remote-dir-is-a-file")]) +def test_a_folder_a_file_stands_in_keeps_its_remediation( + tmp_path, tool, case): + """Every other refusal of a folder says what it always said: a file at + the folder or above it is told to be moved aside, which is true of it.""" + if tool == "scaffold": + put, (work, remotes), said = SCAFFOLD_CASES[case] + run = scaffold_in(tmp_path, work, remotes) + else: + put, landing, said = FAMILY_CASES[case] + run = init_in(tmp_path, *landing) + touch(tmp_path / put) + real = assert_both_runs_refuse(tmp_path, run, said(tmp_path)) + assert remediation_line(real) == FOLDER_REMEDIATION + + +def test_the_folder_remediation_is_chosen_by_what_stands_in_the_way( + tmp_path, monkeypatch): + """`refuse_unmakeable_folder`: the permissions sentence for an answer + that ends `CANNOT_WRITE_IN` (the folder or one above it), naming `choose` + or, with none, `named`; `FOLDER_REMEDIATION` for any other.""" + ask = shape_materialize.refuse_unmakeable_folder + for why, remediation in ( + (f"/x {shape_materialize.CANNOT_WRITE_IN}", + unwritable_remediation("--flag")), + ("/x is a file, not a folder", FOLDER_REMEDIATION), + ("/x cannot be used as a folder: No such file or directory", + FOLDER_REMEDIATION)): + monkeypatch.setattr(shape_materialize, "folder_cannot_be_made", + lambda path, why=why: why) + with pytest.raises(Refusal) as refused: + ask("x-code", "--named", tmp_path, "--flag") + assert refused.value.remediation == remediation + monkeypatch.setattr(shape_materialize, "folder_cannot_be_made", + lambda path: f"/x {shape_materialize.CANNOT_WRITE_IN}") + with pytest.raises(Refusal) as refused: + ask("x-code", "--named", tmp_path) + assert refused.value.remediation == unwritable_remediation("--named") # --- L2-60: an EMPTY working tree this user cannot write in ------------------ @@ -396,6 +506,28 @@ def test_an_unwritable_holder_under_work_dir_says_to_choose_another_work_dir( assert remediation_line(result) == unwritable_remediation("--work-dir") +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real-run"]) +@pytest.mark.parametrize("how", list(CANNOT_WRITE)) +def test_an_unwritable_holder_in_the_family_folder_one_stands_in_is_refused( + tmp_path, unwritable_folder, how, dry_run): + """rv622r's problem 1: with `--into` named for the family, `Orbit/Orbit` + is the holder, and `_landing` asked `(into / family / MANIFEST).is_file()` + of it. For a holder with no search bit that raised `PermissionError`, a + traceback from both runs before any question was asked; with the search + bit it was refused by name. Both are refused by name now, and nothing is + made.""" + tree = unwritable_folder("Orbit/Orbit", CANNOT_WRITE[how]) + scratch_env(tmp_path) + before = snapshot(tmp_path) + result = init_in(tmp_path, "--into", "Orbit")(dry_run=dry_run) + assert_refused(result, f"REFUSED family-target-exists: {tree.resolve()} " + "already exists and is a folder this user cannot " + "write in") + assert remediation_line(result) == unwritable_remediation("--into") + assert snapshot(tmp_path) == before, "the run made something" + assert not list(tmp_path.rglob("*.git")), "a remote was made" + + @pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real-run"]) @pytest.mark.parametrize("held", ["a-file", "a-nonempty-folder"]) @pytest.mark.parametrize("tool", list(TREE_AT))