diff --git a/scaffold-project.py b/scaffold-project.py index c382fa87..50814766 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -47,7 +47,9 @@ value the validator refuses (a blank `--elected-by`, `--reference` or `--name`, an `--org` it cannot hold) is refused as `scaffold-manifest-invalid`, in the validator's own words; an `--elected-on` -is read in the validator's form only, on every Python. A name that cannot be a +is read in the validator's form only, on every Python. A flag passed empty is +a value, refused by name by its own rule, never read as one not given (#911). +A name that cannot be a directory's is refused as `scaffold-name-too-long`, and so is one GitHub will not give a repository; an `--org` longer than GitHub keeps one is `scaffold-org-too-long`, and an id whose topic GitHub will not set is @@ -98,7 +100,7 @@ from shape_materialize import ( # noqa: E402 RULESET_HINT, SHAPE_REPOSITORY, CommandFailed, copy_tree, default_reference, descendant_note, election_date, env_commit, - escape_what_stdout_cannot_encode, git_init_commit, + escape_what_stdout_cannot_encode, folder_flag_path, git_init_commit, materialize_assembly_root, naming_block, refuse_bad_branch, run, stage_written, ) @@ -361,7 +363,10 @@ def main(argv: list[str] | None = None) -> int: "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, + # TEXT, not `type=Path`, as `--pin-source` and `--work-dir` are: `Path("")` + # is `.`, so an empty value could not be told from a typed `.` once + # parsed (`folder_flag_path`). + parser.add_argument("--local-remote-dir", default=None, help="create bare repositories here instead of calling " "`gh repo create` (the TEST path; no network)") parser.add_argument("--no-push", action="store_true") @@ -414,7 +419,7 @@ def main(argv: list[str] | None = None) -> int: "read only at a clone's top level, or a bare " "clone's: any other path is refused as " "`pin-source-not-a-clone`.") - parser.add_argument("--work-dir", type=Path, default=None, + parser.add_argument("--work-dir", default=None, help="where the working trees are built " "(default: a temporary directory)") parser.add_argument("--no-next-steps", action="store_true", @@ -736,7 +741,9 @@ def _refuse_unholdable_manifest(args, values: dict, policy) -> None: refused. A value a flag did not supply is named by where it came from, so the - sentence does not point at a flag nobody typed. + sentence does not point at a flag nobody typed. A flag passed empty + supplied its value, `''`, and is named as the flag (#911), as `_identity` + reads it. """ flags = dict(MANIFEST_FLAGS) for key, flag, given, source in ( @@ -744,7 +751,7 @@ def _refuse_unholdable_manifest(args, values: dict, policy) -> None: ("PROJECT_NAME", "--name", args.name, "--project"), ("ELECTED_BY", "--elected-by", args.elected_by, "GIT_AUTHOR_NAME or `git config user.name`")): - if not given: + if given is None: flags[key] = f"{flag} (not given, so {source})" refuse_manifest_values( "scaffold", values, policy, flags, @@ -758,9 +765,17 @@ def _elector(args) -> str: machine's git identity; the manifest records a name either way, because electing this shape is a human's act and `elected_by` is the record of whose. `_elector_named` says which of the three answered, in this order. + + A `--elected-by ''` IS THE FLAG'S ANSWER (#911). It was read by its + truth, so an empty one fell back to this machine's identity without a + word, and with none `scaffold-no-elector` said "no --elected-by" of a + flag that was passed. It is a value now, and the project's own + validator refuses it by name (`_refuse_unholdable_manifest`), as it + refuses `--elected-by ' '`; the fallback answers only where the flag is + not given. """ elected_by = args.elected_by - if not elected_by: + if elected_by is None: try: # Per-invocation identity first, then the config — never a global # config write (see the CI workflow note, 2026-09-03). The config @@ -775,13 +790,14 @@ def _elector(args) -> str: "utf-8", "surrogateescape").strip()) except Refusal: elected_by = "" - if not elected_by: - raise Refusal( - "scaffold-no-elector", - "no --elected-by, no GIT_AUTHOR_NAME and no `git config user.name`. Electing this " - "shape is a human's act and the manifest records whose.", - "Remediation: re-run with --elected-by 'Your Name'.", - ) + if not elected_by: + raise Refusal( + "scaffold-no-elector", + "no --elected-by, no GIT_AUTHOR_NAME and no `git config " + "user.name`. Electing this shape is a human's act and the " + "manifest records whose.", + "Remediation: re-run with --elected-by 'Your Name'.", + ) return elected_by @@ -797,12 +813,27 @@ def _identity(args, project: str) -> tuple[str, str, str, str, str]: can use. The three free-text values are asked here too, beside the date and for the same reason, whether a file can hold them (#282): before a work directory or a remote exists, and under `--dry-run` alike. + + A FLAG PASSED EMPTY IS A VALUE, NOT A FLAG NOT GIVEN (#911). Each was + read by its truth (`args.id or project.lower()`), so `--id ''`, + `--name ''`, `--elected-on ''`, `--reference ''` and `--elected-by ''` + planned their defaults and exited 0, and a script's `--id "$ID"` with + `ID` unset scaffolded the derived id without a word. Each is read by `is + not None` now, as `adopt-project.py plan` reads its own (#658), and an + empty one meets its flag's own rule and is refused there by name: + `--elected-on ''` by `election_date` just below, `--id ''` as + `scaffold-bad-id` (`_refuse_bad_id`), and the three free-text values by + the project's own validator (`_refuse_unholdable_manifest`). The id is + held to `checked_value`'s alphabet after `_refuse_bad_id`, where an + empty one is named for the id it is not, rather than as `unsafe-value`. """ - project_id = checked_value("--id", args.id or project.lower()) - display = args.name or project - elected_on = args.elected_on or _dt.date.today().isoformat() + project_id = args.id if args.id is not None else project.lower() + display = args.name if args.name is not None else project + elected_on = (args.elected_on if args.elected_on is not None + else _dt.date.today().isoformat()) election_date(elected_on) - reference = args.reference or default_reference(elected_on) + reference = (args.reference if args.reference is not None + else default_reference(elected_on)) elected_by = _elector(args) _refuse_unwritable_values(( (_elector_named(args), elected_by, ROOT_MANIFEST_AND_README), @@ -831,35 +862,68 @@ def _identity(args, project: str) -> tuple[str, str, str, str, str]: #: digits and hyphens, so "pass an explicit lowercase --id" led straight to #: `naming-unclassified`, and so would "change --project" to `North-Wind`. #: The derived remedy names the rule. -PASSED_ID_REMEDIATION = "Remediation: pass an explicit lowercase --id." +#: +#: A PASSED ONE IS TOLD WHAT AN ID MAY HOLD (#911): `PROJECT_ID_RE` in +#: words. "Pass an explicit lowercase --id" was said to `--id foo_bar`, +#: which is both, and whose fault is the underscore. `family-bad-id` says +#: the same sentence of its own `--id`. +PASSED_ID_REMEDIATION = ("Remediation: pass an --id of lowercase letters, " + "digits and hyphens, starting with a letter or " + "digit.") DERIVED_ID_REMEDIATION = ( f"Remediation: change --project to {PROJECT_NAME_RULE}. The id is " f"{ID_DERIVED_FROM}, and an explicit --id would not change --project, " "which also names the assembly root and both legs.") -def _refuse_bad_id(project_id: str, given: str | None) -> None: +def _refuse_bad_id(project_id: str, given: str | None, policy, + project: str) -> None: """`scaffold-bad-id`: an id the project's validators would not take (`PROJECT_ID_RE`), named by where it came from (#659), as `adopt-project.py plan` names `adopt-bad-id` (#658). - `given` is `--id` as parsed. One the person passed is named as the flag - they typed, value as typed, and keeps its remedy. With none, or an empty - one, `_identity` derived the id (`args.id or project.lower()`), and - `--id 'north_wind' must match` named a value nobody had typed: the flag - is named as `_refuse_unholdable_manifest` names one, by where its value - came from, and an empty `--id ''` as empty, because it was passed.""" + `given` is `--id` as parsed. One the person passed, `''` among them + (#911), is named as the flag they typed, value as typed, and is told + what an id may hold (`PASSED_ID_REMEDIATION`). With none, `_identity` + derived the id from `--project`, and `--id 'north_wind' must match` + named a value nobody had typed: the flag is named as + `_refuse_unholdable_manifest` names one, by where its value came from. + + A REFUSED NAME SPEAKS FIRST (#911). `--project North_Wind --id + north_wind` was refused for its id, and the remedy's retype then met + `naming-unclassified`, which no `--id` mends. So a passed id is refused + only once `--project` is a name the naming policy holds as an assembly + root; one it does not is refused here as `_check_one_name` refuses the + assembly root's name, which `_check_declared_names` asks later, after + `_declaration`. The question is the policy's answer for the name in the + `assembly` role with nothing declared, and NOTHING DECLARED TURNS ITS NO + INTO A YES (#972's review, M1): `classify` answers None only when + `matches` finds no pattern; a form decided by its characters alone + (`UNAMBIGUOUS_FORMS`) carries the same role whatever is pinned; and a + pin makes a descendant form the assembly root only where the name also + satisfies the `project-leg/assembly` form, which it answers with no pin + too. So `Foo-Install` is refused here, and so are `Atlas-spec` and + `Atlas-code`, a leg's form with a pin or without: `--project Atlas-spec + --id foo_bar` was told to retype the id, and `--id foo-bar` then met + `naming-role-mismatch`. A name a pin can make the root, `MedxGlass`, + passes here, and every answer is asked with no process started. A + derived id keeps its own refusal: its remedy is already the naming + rule.""" if PROJECT_ID_RE.match(project_id): return pattern = PROJECT_ID_RE.pattern - if given: + if given is not None: + found = policy.classify(project, "assembly") + if found is None: + raise _unclassified(project) + if not accepts_role(found, "assembly"): + raise _role_mismatch(project, found, "assembly") raise Refusal("scaffold-bad-id", f"--id {project_id!r} must match {pattern}", PASSED_ID_REMEDIATION) - unset = "not given" if given is None else "empty" raise Refusal("scaffold-bad-id", - f"--id ({unset}, so {ID_DERIVED_FROM}) is {project_id!r}, " - f"which must match {pattern}", + f"--id (not given, so {ID_DERIVED_FROM}) is " + f"{project_id!r}, which must match {pattern}", DERIVED_ID_REMEDIATION) @@ -902,18 +966,18 @@ def _refuse_what_github_cannot_hold(args, project_id: str, policy) -> None: which a `--project` of 40 or more gives by default, failed there with all three repositories created and seeded. Under `--local-remote-dir` neither is asked: a bare repository on disk has no topic and no owner. - The id is named as `_refuse_bad_id` names it, by where it came from.""" + The id is named as `_refuse_bad_id` names it, by where it came from; a + `--id ''` was refused there (#911), so a derived id is one not given.""" if args.local_remote_dir is not None: return refuse_an_owner_github_cannot_have("scaffold-org-too-long", args.org, "--org", ORG_REMEDIATION) refuse_an_owner_github_cannot_spell("scaffold-bad-org", args.org, "--org", ORG_SPELLING_REMEDIATION) - if args.id: + if args.id is not None: named, remediation = "--id", PASSED_TOPIC_REMEDIATION else: - unset = "not given" if args.id is None else "empty" - named = f"--id ({unset}, so {ID_DERIVED_FROM})" + named = f"--id (not given, so {ID_DERIVED_FROM})" remediation = DERIVED_TOPIC_REMEDIATION refuse_a_topic_github_cannot_set( "scaffold-topic-too-long", policy.topic_for(project_id), project_id, @@ -958,8 +1022,9 @@ def _refuse_what_github_cannot_hold(args, project_id: str, policy) -> None: def _elector_named(args) -> str: """Where `_elector`'s answer came from, as a refusal names it (#282): `--elected-by`, else `ELECTOR_FROM_ENV` when GIT_AUTHOR_NAME holds more - than whitespace, else `ELECTOR_FROM_CONFIG` -- `_elector`'s own order.""" - if args.elected_by: + than whitespace, else `ELECTOR_FROM_CONFIG` -- `_elector`'s own order. + A `--elected-by ''` is the flag's answer (#911).""" + if args.elected_by is not None: return "--elected-by" if (os.environ.get("GIT_AUTHOR_NAME") or "").strip(): return ELECTOR_FROM_ENV @@ -1189,6 +1254,33 @@ def _check_referent_chain(policy, assembly_name: str, declared_pins: set, print(f"WARNING {warning}", file=sys.stderr) +def _unclassified(name: str) -> Refusal: + """`naming-unclassified`: `name` matches no form in the naming policy. + One refusal, raised by `_check_one_name` and, for a passed `--id` the + project's validators would not take, first by `_refuse_bad_id` (#911).""" + return Refusal( + "naming-unclassified", + f"{name!r} matches no family in the naming policy. " + f"`` is {PROJECT_NAME_RULE}.", + "Remediation: re-run with a --project value of that form.", + ) + + +def _role_mismatch(name: str, found, role: str) -> Refusal: + """`naming-role-mismatch`: `name` classifies as `found`, a form its + `role` does not accept. Raised by `_check_one_name` and, for a passed + `--id` that is no id, first by `_refuse_bad_id` (#911).""" + return Refusal( + "naming-role-mismatch", + f"{name!r} classifies as {found[0]}" + + (f"/{found[1]}" if found[1] else "") + + f", not as the {role!r} form of a project leg" + + f" ({found.reason})", + "Remediation: re-run with a --project value that is one " + "CamelCase token.", + ) + + def _check_one_name(policy, name: str, role: str, declared_pins: set, chain: tuple, link_pins: dict) -> None: """(e) ONE of the three names classifies as a form its role accepts, or @@ -1197,22 +1289,9 @@ def _check_one_name(policy, name: str, role: str, declared_pins: set, found = policy.classify(name, role, declared_pins, chain if role == "assembly" else (), link_pins) if found is None: - raise Refusal( - "naming-unclassified", - f"{name!r} matches no family in the naming policy. " - f"`` is {PROJECT_NAME_RULE}.", - "Remediation: re-run with a --project value of that form.", - ) + raise _unclassified(name) if not accepts_role(found, role): - raise Refusal( - "naming-role-mismatch", - f"{name!r} classifies as {found[0]}" - + (f"/{found[1]}" if found[1] else "") - + f", not as the {role!r} form of a project leg" - + f" ({found.reason})", - "Remediation: re-run with a --project value that is one " - "CamelCase token.", - ) + raise _role_mismatch(name, found, role) def _check_declared_names(policy, names: dict, declared_pins: set, @@ -1344,7 +1423,8 @@ def _print_plan(args, values: dict, names: dict, repositories: dict, f"(tree {values['SHAPE_TREE_SHA256'][:12]}…)") print(f"elected by {values['ELECTED_BY']} on {values['ELECTED_ON']}") print(f"reference {values['REFERENCE']}" - + ("" if args.reference else " (chosen by the election date)")) + + ("" if args.reference is not None + else " (chosen by the election date)")) for role in ("assembly", "spec", "code"): print(f" {role:<9} {repositories[role]:<28} -> {urls[role]}") for role in ("assembly", "spec", "code"): @@ -1409,7 +1489,10 @@ def _refuse_existing_trees(work_root: Path, names: dict) -> None: 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. + `target_in_the_way` asks both, and makes nothing. A folder this user + cannot write in (L2-60) or cannot list (#930) is told the permission and + `--work-dir` by `target_remediation`, not this sentence about a live + tree, which the run cannot see there. """ for role, name in names.items(): target = work_root / name @@ -1856,6 +1939,19 @@ def _scaffold(args) -> int: args.code_path = checked_value(*code_leg) args.org = checked_value("--org", args.org) args.pin_owner = checked_value("--pin-owner", args.pin_owner) + # A FOLDER FLAG THAT NAMES NO PATH, `''` (#972's review, S2), which as a + # `Path` is the directory this runs in, as `--pin-source ''` is just + # below: read as `.` it built the trees there, or made the bare + # repositories there, without a word. Asked before anything reads them. + args.work_dir = folder_flag_path( + args.work_dir, "--work-dir", "work-dir-no-path", + names="the folder the working trees are built in", + absent="they are built in a temporary directory") + args.local_remote_dir = folder_flag_path( + args.local_remote_dir, "--local-remote-dir", + "local-remote-dir-no-path", + names="the folder the bare repositories are made in", + absent="they are created on GitHub") # A `--pin-source` THAT NAMES NO PATH, `''`, which as a `Path` is the # directory this runs in, and then one NOTHING WILL READ (#451): no # `--pin` reads a digest there and no `--referent-chain` link a @@ -1906,7 +2002,11 @@ def _scaffold(args) -> int: local, repositories, urls = _repository_urls(args, names) _refuse_an_unwritable_clone_url(local, urls["assembly"]) policy = NamingPolicy.load(SHAPE_ROOT / "contracts" / "repository-naming.yaml") - _refuse_bad_id(project_id, args.id) + _refuse_bad_id(project_id, args.id, policy, project) + # AND THE ALPHABET A COMMAND LINE'S VALUE IS HELD TO, after the id's own + # rule (#911), so `--id ''` is named for the id it is not; this still + # refuses what `PROJECT_ID_RE.match` lets through, a trailing line break. + project_id = checked_value("--id", project_id) _refuse_what_github_cannot_hold(args, project_id, policy) # WHAT THE PROJECT DECLARES ABOUT ITSELF, before any name is judged. # With no `--pin`, a descendant-form name is a claim with no referent and diff --git a/scripts/family.py b/scripts/family.py index e8ed8bd6..cd7592f3 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -186,7 +186,7 @@ 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, make_folder, + folder_flag_path, force_stage, inherited_child_environment, make_folder, materialize_family_root, refuse_bad_branch, refuse_unless_not_found, refuse_unmakeable_folder, run, shown_source, stage_written, target_in_the_way, target_remediation, trailer_line, write_lf, @@ -683,8 +683,15 @@ def _member_url(repository: str, local_remote_dir: Path | None) -> str: #: (`_classify_family_name`). `family-bad-id`'s derived remedy names it in #: the same words, so the person who follows it is not sent from one #: refusal to the other (#659). +#: +#: AND NOT ONE OF THE TWO FORMS NO HOLDER IS (#911), in +#: `naming-not-a-family`'s words. `--family open_glass` was told to change +#: `--family` to one CamelCase token, and `openGlass`, which is one, was +#: then refused as `naming-not-a-family`: an `open` name classifies +#: as a neutral product whatever is declared. FAMILY_NAME_RULE = ("one CamelCase token with no hyphen, underscore, dot " - "or space") + "or space, and not an `open` or `-Install` " + "name") def _classify_family_name(policy: NamingPolicy, name: str) -> None: @@ -700,9 +707,10 @@ def _classify_family_name(policy: NamingPolicy, name: str) -> None: raise Refusal( "naming-unclassified", f"{name!r} matches no family in the naming policy", - f"Remediation: --family takes {FAMILY_NAME_RULE} — the same rule " - "an assembly root's name follows, because a holder is spelled " - "like one.") + f"Remediation: --family takes {FAMILY_NAME_RULE}. A holder is " + "spelled like an assembly root, but those two forms are " + "unambiguous by construction and cannot be declared into a " + "holder.") if found.family != "family": raise Refusal( "naming-not-a-family", @@ -2029,6 +2037,33 @@ def _refuse_landing_in_standard(into: Path, holder: Path, "override (`/`, no family folder). There is no --force.") +def _folder_flags(args) -> None: + """`init`'s three folder flags as the `Path`s every later step reads, + each refused by name when passed empty (#972's review, S2), first, before + anything reads one: `--into ''` as `into-no-path`, `--work-dir ''` as + `work-dir-no-path` and `--local-remote-dir ''` as + `local-remote-dir-no-path`, as the scaffold refuses its own two. + + As a `Path`, `''` is `.`, the directory `init` runs in, so each was taken + without a word: from the root of a checkout of openRepoShape, `--into + ''` planned the holder inside it, where no `--into` is refused as + `family-landing-in-standard`. A flag not given is None, as it was.""" + args.into = folder_flag_path( + args.into, "--into", "into-no-path", + names="the parent folder the family folder is made in", + absent="it is made in the directory you stand in, which is refused " + "inside a checkout of openRepoShape") + args.work_dir = folder_flag_path( + args.work_dir, "--work-dir", "work-dir-no-path", + names="the folder the holder is made in", + absent="the holder lands in a family folder, by --into") + args.local_remote_dir = folder_flag_path( + args.local_remote_dir, "--local-remote-dir", + "local-remote-dir-no-path", + names="the folder the holder's bare repository is made in", + absent="it is created on GitHub") + + def _landing(args, family: str) -> tuple[Path | None, Path]: """`(the family folder, the holder)` — WHERE `init` PUTS THINGS. @@ -2093,7 +2128,12 @@ def _landing(args, family: str) -> tuple[Path | None, Path]: #: forms take only ASCII letters, digits and hyphens, so "pass an explicit #: lowercase --id" led straight to `naming-unclassified`, and so would #: "change --family" to `North-Wind`. The derived remedy names the rule. -PASSED_FAMILY_ID_REMEDIATION = "Remediation: pass an explicit lowercase --id." +#: A passed one is told what an id may hold (#911), in the scaffold's words +#: for `scaffold-bad-id`: "an explicit lowercase --id" was said to `--id +#: foo_bar`, which is both. +PASSED_FAMILY_ID_REMEDIATION = ("Remediation: pass an --id of lowercase " + "letters, digits and hyphens, starting with " + "a letter or digit.") DERIVED_FAMILY_ID_REMEDIATION = ( f"Remediation: change --family to {FAMILY_NAME_RULE}. The id is " f"{FAMILY_ID_DERIVED_FROM}, and an explicit --id would not change " @@ -2101,27 +2141,45 @@ def _landing(args, family: str) -> tuple[Path | None, Path]: "its family folder.") -def _refuse_bad_family_id(family_id: str, given: str | None) -> None: +def _refuse_bad_family_id(family_id: str, args, policy: NamingPolicy, + family: str) -> None: """`family-bad-id`: an id `validate-family.py` would not take (`PROJECT_ID_RE`), named by where it came from (#659), as the scaffold names `scaffold-bad-id`. - `given` is `--id` as parsed. One the person passed is named as the flag - they typed, value as typed, and keeps its remedy. With none, or an empty - one, the id is `--family` lowercased (`args.id or family.lower()`), and - `--id 'north_wind' must match` named a value nobody had typed: the flag - is named as `_manifest_flags` names one, by where its value came from, - and an empty `--id ''` as empty, because it was passed.""" + `args.id` is `--id` as parsed. One the person passed, `''` among them + (#911), is named as the flag they typed, value as typed, and is told + what an id may hold (`PASSED_FAMILY_ID_REMEDIATION`). With none, the id + is `--family` lowercased, and `--id 'north_wind' must match` named a + value nobody had typed: the flag is named as `_manifest_flags` names + one, by where its value came from. + + A REFUSED NAME SPEAKS FIRST (#911). `--family North_Wind --id + north_wind` was refused for its id, and the remedy's retype then met + `naming-unclassified`, which no `--id` mends. So a passed id is refused + only once `--family` is a name a holder can have: one the policy holds + as a holder (`_classify_family_name`), one a Windows disk can make a + folder of (`_refuse_an_unholdable_holder_name`), and, where GitHub is + asked, one GitHub keeps a repository's name to + (`_refuse_a_holder_name_github_cannot_hold`). `_checked_family_identity` + asks each of them next anyway, in this order, and none reads anything + but the name, the policy and whether `--local-remote-dir` was given, so + none starts a process: `--family NUL --id foo_bar` and a + `--family` of 105 characters with a bad id were told to retype the id + first (#972's review, S1). A derived id keeps its own refusal: its + remedy is already the naming rule.""" if PROJECT_ID_RE.match(family_id): return pattern = PROJECT_ID_RE.pattern - if given: + if args.id is not None: + _classify_family_name(policy, family) + _refuse_an_unholdable_holder_name(family) + _refuse_a_holder_name_github_cannot_hold(args, family) raise Refusal("family-bad-id", f"--id {family_id!r} must match {pattern}", PASSED_FAMILY_ID_REMEDIATION) - unset = "not given" if given is None else "empty" raise Refusal("family-bad-id", - f"--id ({unset}, so {FAMILY_ID_DERIVED_FROM}) is " + f"--id (not given, so {FAMILY_ID_DERIVED_FROM}) is " f"{family_id!r}, which must match {pattern}", DERIVED_FAMILY_ID_REMEDIATION) @@ -2159,6 +2217,23 @@ def _refuse_bad_family_id(family_id: str, given: str | None) -> None: "`` of `https://github.com/`.") +def _refuse_a_holder_name_github_cannot_hold(args, family: str) -> None: + """`family-name-too-long`: a `--family` over GitHub's 100 for the + holder's repository, `/` (#863). Asked first by + `_refuse_what_github_cannot_hold`, and before a passed id that is no id + (`_refuse_bad_family_id`, #972's review, S1). Not asked under + `--local-remote-dir`, where a bare repository's name is the disk's.""" + if args.local_remote_dir is not None: + return + if len(family) > GITHUB_NAME_MAX: + raise Refusal( + "family-name-too-long", + f"--family is {_both_ends(family)}, {len(family)} characters, and " + "GitHub keeps a repository's name, the holder's " + f"`/`, to at most {GITHUB_NAME_MAX}", + HOLDER_TOO_LONG_REMEDIATION) + + def _refuse_what_github_cannot_hold(args, family: str, family_id: str, topic: str) -> None: """What GitHub will not take of the holder, refused where GitHub is @@ -2179,25 +2254,19 @@ def _refuse_what_github_cannot_hold(args, family: str, family_id: str, failed at `gh repo create`. Under `--local-remote-dir` none is asked: a bare repository on disk has no topic and no owner, and its name is the disk's. The id is named as `_refuse_bad_family_id` names it, by where it - came from.""" + came from; a `--id ''` was refused there (#911), so a derived id is one + not given.""" if args.local_remote_dir is not None: return - if len(family) > GITHUB_NAME_MAX: - raise Refusal( - "family-name-too-long", - f"--family is {_both_ends(family)}, {len(family)} characters, and " - "GitHub keeps a repository's name, the holder's " - f"`/`, to at most {GITHUB_NAME_MAX}", - HOLDER_TOO_LONG_REMEDIATION) + _refuse_a_holder_name_github_cannot_hold(args, family) refuse_an_owner_github_cannot_have("family-org-too-long", args.org, "--org", ORG_REMEDIATION) refuse_an_owner_github_cannot_spell("family-bad-org", args.org, "--org", ORG_SPELLING_REMEDIATION) - if args.id: + if args.id is not None: named, remediation = "--id", PASSED_TOPIC_REMEDIATION else: - unset = "not given" if args.id is None else "empty" - named = f"--id ({unset}, so {FAMILY_ID_DERIVED_FROM})" + named = f"--id (not given, so {FAMILY_ID_DERIVED_FROM})" remediation = DERIVED_TOPIC_REMEDIATION refuse_a_topic_github_cannot_set("family-topic-too-long", topic, family_id, named, remediation) @@ -2223,9 +2292,16 @@ def _checked_family_identity(args) -> tuple[str, str, str]: # too, before anything is created. refuse_bad_branch("family-bad-tracking-branch", "--tracking-branch", args.tracking_branch) - family_id = checked_value("--id", args.id or family.lower()) - _refuse_bad_family_id(family_id, args.id) + # A `--id ''` IS A VALUE, NOT A FLAG NOT GIVEN (#911): read by its truth, + # it planned `--family` lowercased without a word. Read by `is not None`, + # it is refused as `family-bad-id`, by name, before `checked_value` holds + # the id to a command line's alphabet, which would call it `unsafe-value` + # and which still refuses what `PROJECT_ID_RE.match` lets through, a + # trailing line break. + family_id = args.id if args.id is not None else family.lower() policy = NamingPolicy.load(NAMING_POLICY) + _refuse_bad_family_id(family_id, args, policy, family) + family_id = checked_value("--id", family_id) _classify_family_name(policy, family) _refuse_an_unholdable_holder_name(family) topic = policy.topic_for(family_id) @@ -2420,37 +2496,44 @@ def _creator_name(args) -> str: set but blank and nothing else names a creator, the refusal says that variable is there and is the cause, instead of "no GIT_AUTHOR_NAME", and tells the person to unset it AND pass `--created-by`. + + A `--created-by ''` IS THE FLAG'S ANSWER (#911). It was read by its + truth, so an empty one fell back to GIT_AUTHOR_NAME or `git config + user.name` without a word, and with neither `family-no-creator` said "no + --created-by" of a flag that was passed. It is a value now, and the + holder's own validator refuses it by name (`refuse_unholdable_manifest`), + as it refuses `--created-by ' '`; the fallbacks, and both refusals that + no creator was found, answer only where the flag is not given. """ created_by, source = args.created_by, "--created-by" - blank_variable = False - if not created_by: + if created_by is None: named = os.environ.get("GIT_AUTHOR_NAME") or "" created_by, source = named.strip(), "GIT_AUTHOR_NAME" blank_variable = bool(named) and not created_by - if not created_by: - source = "`git config user.name`" - try: - created_by = git_out(["config", "user.name"], cwd=SHAPE_ROOT, - binary=True).decode( - "utf-8", "surrogateescape").strip() - except Refusal: - created_by = "" - if not created_by and blank_variable: - raise Refusal( - "family-no-creator", - "no --created-by, a GIT_AUTHOR_NAME of whitespace alone, and no " - "`git config user.name`. Creating a family is a human's act and " - "the manifest records whose.", - "Remediation: unset GIT_AUTHOR_NAME, which git would make the " - "holder's first commit as and refuses, and re-run with " - "--created-by 'Your Name'.") - if not created_by: - raise Refusal( - "family-no-creator", - "no --created-by, no GIT_AUTHOR_NAME and no `git config " - "user.name`. Creating a family is a human's act and the manifest " - "records whose.", - "Remediation: re-run with --created-by 'Your Name'.") + if not created_by: + source = "`git config user.name`" + try: + created_by = git_out(["config", "user.name"], cwd=SHAPE_ROOT, + binary=True).decode( + "utf-8", "surrogateescape").strip() + except Refusal: + created_by = "" + if not created_by and blank_variable: + raise Refusal( + "family-no-creator", + "no --created-by, a GIT_AUTHOR_NAME of whitespace alone, and " + "no `git config user.name`. Creating a family is a human's " + "act and the manifest records whose.", + "Remediation: unset GIT_AUTHOR_NAME, which git would make the " + "holder's first commit as and refuses, and re-run with " + "--created-by 'Your Name'.") + if not created_by: + raise Refusal( + "family-no-creator", + "no --created-by, no GIT_AUTHOR_NAME and no `git config " + "user.name`. Creating a family is a human's act and the " + "manifest records whose.", + "Remediation: re-run with --created-by 'Your Name'.") reason = unwritable_reason(created_by) if reason: raise Refusal( @@ -2655,17 +2738,23 @@ def _template_values(args, family: str, family_id: str, repository: str, Windows path, spelled with `\\`, is quoted). Only this value is quoted: the block's other placeholder, `{{FAMILY}}` in `cd {{FAMILY}}`, is a name the naming policy spells in letters and digits. + + A `--name ''` OR `--created-on ''` IS A VALUE (#911), read by `is not + None` as `--id` and `--created-by` are, so the holder's own validator + refuses it by name (`refuse_unholdable_manifest`) where its truth made + it the default without a word. """ return { "FAMILY": family, - "FAMILY_NAME": args.name or family, + "FAMILY_NAME": args.name if args.name is not None else family, "FAMILY_ID": family_id, "FAMILY_REPOSITORY": repository, "ORG": args.org, "VISIBILITY": args.visibility, "TRACKING_BRANCH": args.tracking_branch, "CREATED_BY": created_by, - "CREATED_ON": args.created_on or _dt.date.today().isoformat(), + "CREATED_ON": (args.created_on if args.created_on is not None + else _dt.date.today().isoformat()), "CLONE_URL": shell_quoted(url, "posix"), "SHAPE_REPOSITORY": SHAPE_REPOSITORY, "SHAPE_COMMIT": shape_commit, @@ -2755,7 +2844,9 @@ def _refuse_a_taken_holder(folder: Path | None, holder: Path) -> None: `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.""" + otherwise. A folder this user cannot list (#930) is told the same flag, + with the permission that would let `init` see whether it holds a tree, + and not this sentence about a holder that is already there.""" why = target_in_the_way(holder) if why: raise Refusal( @@ -3334,7 +3425,9 @@ def family_manifest_invalid(values: dict, policy: NamingPolicy, what: dict, def _manifest_flags(args) -> dict: """`MANIFEST_FLAGS`, with a value no flag supplied named by where it came - from, so that a refusal does not point at a flag nobody typed.""" + from, so that a refusal does not point at a flag nobody typed. A flag + passed empty supplied its value, `''`, and is named as the flag (#911), + as `_template_values` and `_creator_name` read it.""" flags = dict(MANIFEST_FLAGS) for key, flag, given, source in ( ("FAMILY_ID", "--id", args.id, FAMILY_ID_DERIVED_FROM), @@ -3342,7 +3435,7 @@ def _manifest_flags(args) -> dict: ("CREATED_BY", "--created-by", args.created_by, "GIT_AUTHOR_NAME or `git config user.name`"), ("CREATED_ON", "--created-on", args.created_on, "today")): - if not given: + if given is None: flags[key] = f"{flag} (not given, so {source})" return flags @@ -3375,6 +3468,7 @@ def refuse_unholdable_manifest(args, values: dict, def cmd_init(args) -> int: + _folder_flags(args) family, family_id, topic = _checked_family_identity(args) created_by = _creator_name(args) local, repository, url = _holder_remote(args, family) @@ -9631,17 +9725,20 @@ def build_parser() -> argparse.ArgumentParser: "commits, use it as the holder instead of " "refusing. A repository with commits is still " "refused: that is live, not a slot.") - init.add_argument("--local-remote-dir", type=Path, default=None, + # THE THREE FOLDER FLAGS ARE TEXT, not `type=Path`: `Path("")` is `.`, + # so an empty value could not be told from a typed `.` once parsed. + # `_folder_flags` makes each a `Path`, refusing an empty one first. + init.add_argument("--local-remote-dir", default=None, help="create a bare repository here instead of calling " "`gh repo create` (the TEST path; no network)") - init.add_argument("--into", type=Path, default=None, metavar="DIR", + init.add_argument("--into", default=None, metavar="DIR", help="the PARENT directory the family folder is made " "in (default: the directory you are standing in, " "which is refused inside a checkout of " "openRepoShape: from its root, pass `--into ..`). " "The holder lands at //, " "beside where `make siblings` clones the members.") - init.add_argument("--work-dir", type=Path, default=None, + init.add_argument("--work-dir", default=None, help="THE OVERRIDE: land the holder at " "/ and make no family folder at " "all. Mutually exclusive with --into.") diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 922f791a..e3c7bdbb 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -1517,6 +1517,10 @@ def readable_branch(what: str, value) -> str: #: empty working tree) alike. CANNOT_WRITE_IN = "is a folder this user cannot write in" +#: How `target_in_the_way` begins its answer for a working tree this user +#: cannot list (#930): `cannot be read: `. +CANNOT_READ = "cannot be read" + #: 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 = ( @@ -1595,6 +1599,18 @@ def _unwritable_remediation(choose: str) -> str: f"folder, or choose another {choose}, and run this again.") +def _unreadable_remediation(choose: str) -> str: + """What a refusal of a working tree this user cannot list says to do + (#930), after `_unwritable_remediation`: the run cannot tell whether the + folder holds a tree, so it names the permission that would let it ask, + and the flag `choose` that chooses another folder.""" + return ("Remediation: nothing was created, and `--dry-run` refuses the " + "same. This user cannot list that folder, so whether it holds a " + "tree cannot be asked: give this user read, write and search " + f"permission on it, 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. @@ -1661,7 +1677,7 @@ def target_in_the_way(target: Path) -> str | None: if any(target.iterdir()): return "is not empty" except OSError as exc: - return f"cannot be read: {exc.strerror or exc}" + return f"{CANNOT_READ}: {exc.strerror or exc}" if not os.access(target, os.W_OK | os.X_OK): return CANNOT_WRITE_IN return None @@ -1676,10 +1692,19 @@ def target_remediation(why: str, holds: str, choose: str) -> str: 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 _unwritable_remediation(choose) + `choose`, the flag the person met it by. + + AND FOR A FOLDER THIS USER CANNOT LIST (`CANNOT_READ`, #930). An empty + folder at mode 000, 100, 200 or 300 was told "choose an empty + --work-dir. There is no --force: re-running over a live tree is not a + scaffold", a cause the run cannot see: it cannot tell an empty folder + from a live tree there, and the one cause it can see is a permission. + It is told what `_unreadable_remediation` says, naming `choose`.""" + if why == CANNOT_WRITE_IN: + return _unwritable_remediation(choose) + if why.startswith(f"{CANNOT_READ}: "): + return _unreadable_remediation(choose) + return holds # --------------------------------------------------------------------------- @@ -3699,6 +3724,39 @@ def pin_source_path(typed: str | None, *, nothing_made: str) -> Path | None: return Path(typed) +def folder_flag_path(typed, flag: str, code: str, *, names: str, + absent: str) -> Path | None: + """A folder flag as the `Path` the tool reads, or None when it was not + given; a value that names no path is refused as `code`. + + `pin_source_path`'s question, asked of the folders the scaffold and + `family.py init` make things in (#972's review, S2): `--work-dir` and + `--local-remote-dir` in both, and `init`'s `--into`. Each was parsed as + a `Path`, and `Path("")` is `Path(".")`, so a flag passed empty was read + as the directory the tool ran in, without a word: from the root of a + checkout of openRepoShape, `init --into ''` planned the holder inside + it, where no `--into` is refused as `family-landing-in-standard`. Each + tool takes them as TEXT now and asks here, before anything exists and + before any process starts, under `--dry-run` alike. A typed `.` still + means the current directory, and a flag not given keeps its default. + + `names` says what the folder is for and `absent` what the run does with + no flag, each in the tool's words, so the remedy names the three ways + on: a path, `.`, or no flag at all. + """ + if typed is None: + return None + if not str(typed): + raise Refusal( + code, + f"`{flag} ''` names no path. An empty path would be read as the " + "directory this runs in.", + "Remediation: nothing was created, and `--dry-run` refuses the " + f"same. Give {names} (`{flag} `), or `.` to mean this " + f"directory; with no {flag}, {absent}.") + return Path(typed) + + def refuse_an_unread_pin_source(pin_source, pins, chain=None, *, nothing_made: str) -> None: """`--pin-source` is read by this run, or this refuses it as diff --git a/tests/test_derived_id_refusals.py b/tests/test_derived_id_refusals.py index a2eb0883..dff972b8 100644 --- a/tests/test_derived_id_refusals.py +++ b/tests/test_derived_id_refusals.py @@ -17,10 +17,18 @@ (not given, so --project lowercased)`, the label each tool's manifest refusals already give it, and its remedy is to change the flag it came from, which also names what the run makes. A `--id` the person passed keeps -today's sentence and remedy, value as typed, even when it equals the derived -one: what is read is whether the flag was passed, not what its value is. An -empty `--id ''` derives the id as no `--id` does (`args.id or ...`), and is -named as empty, because it was passed. +today's sentence, value as typed, even when it equals the derived one: what +is read is whether the flag was passed, not what its value is. + +AND #911 (#459's U156, U158, U159 and U162). A passed `--id` was told "pass +an explicit lowercase --id" whatever its fault, `foo_bar` included, which is +both; it is told what an id may hold now (`PROJECT_ID_RE` in words). A +passed one was refused before the name, so `--project North_Wind --id +north_wind` was sent to retype the id and then met `naming-unclassified`; +the name speaks first now. An empty `--id ''` derived the id as no `--id` +does (`args.id or ...`); it is a passed value now, refused by name. And +`init`'s derived remedy led `--family open_glass` to `openGlass`, which +`naming-not-a-family` refuses; the rule it names says so now. Every run is a `--dry-run` into `--local-remote-dir`, which never calls `gh`. """ @@ -40,21 +48,31 @@ PATTERN = PROJECT_ID_RE.pattern #: The rule each tool's `naming-unclassified` holds a name to, in its own -#: words: the scaffold's and `init`'s are the same. +#: words: the scaffold's is the token rule; `init`'s adds the two forms no +#: holder can be declared as (#911), in `naming-not-a-family`'s words. NAME_RULE = "one CamelCase token with no hyphen, underscore, dot or space" +NOT_A_HOLDER = "and not an `open` or `-Install` name" #: Per tool: the command, the flag naming whoever creates it, the flag the -#: id is derived from and what else that flag names, and the finding's code. +#: id is derived from and what else that flag names, the finding's code, and +#: the naming rule its derived remedy names. TOOLS = { "scaffold": {"command": (SCAFFOLD,), "who": "--elected-by", "flag": "--project", "code": "scaffold-bad-id", - "names": "the assembly root and both legs"}, + "names": "the assembly root and both legs", + "rule": NAME_RULE}, "family": {"command": (FAMILY, "init"), "who": "--created-by", "flag": "--family", "code": "family-bad-id", "names": "the holder and, unless --work-dir is given, its " - "family folder"}, + "family folder", + "rule": f"{NAME_RULE}, {NOT_A_HOLDER}"}, } -PASSED_REMEDY = "Remediation: pass an explicit lowercase --id." +#: What a passed `--id` is told (#911): `PROJECT_ID_RE` in words, the same +#: sentence from both tools. +PASSED_REMEDY = ("Remediation: pass an --id of lowercase letters, digits and " + "hyphens, starting with a letter or digit.") +#: The remedy both tools gave a passed `--id` before #911. +OLD_PASSED_REMEDY = "Remediation: pass an explicit lowercase --id." def derived(tool: str, unset: str) -> str: @@ -67,9 +85,9 @@ def derived_remedy(tool: str) -> str: """The remedy `tool` gives for an id derived from its flag: change the flag, to a name the naming policy takes.""" flag = TOOLS[tool]["flag"] - return (f"Remediation: change {flag} to {NAME_RULE}. The id is {flag} " - f"lowercased, and an explicit --id would not change {flag}, " - f"which also names {TOOLS[tool]['names']}.") + return (f"Remediation: change {flag} to {TOOLS[tool]['rule']}. The id is " + f"{flag} lowercased, and an explicit --id would not change " + f"{flag}, which also names {TOOLS[tool]['names']}.") #: Every character a name may hold on a command line (`SAFE_ARG_RE`, which @@ -102,11 +120,13 @@ def refusal(tool: str, what: str, remedy: str) -> str: def assert_named_as_passed(tool: str, stderr: str, value: str) -> None: """A passed `--id` gets none of a derived id's words, neither its label - ("not given") nor its remedy ("change --project"), and keeps today's - sentence and remedy, value as typed. The derived words are asked first, - so a run that gives a passed id either of them fails on that word.""" + ("not given") nor its remedy ("change --project"), and keeps its + sentence, value as typed, with the remedy that says what an id may hold + (#911). The derived words are asked first, so a run that gives a passed + id either of them fails on that word.""" assert "not given" not in stderr assert f"change {TOOLS[tool]['flag']}" not in stderr + assert OLD_PASSED_REMEDY not in stderr assert refusal(tool, f"--id {value!r} must match {PATTERN}", PASSED_REMEDY) in stderr @@ -125,30 +145,51 @@ def test_a_derived_id_is_named_by_where_it_came_from_and_sent_to_that_flag( @pytest.mark.parametrize("tool", TOOLS) -def test_an_empty_id_is_named_as_empty_and_not_as_not_given(tmp_path, tool): - """`--id ''` was passed, so "not given" would be false of it; the id is - still the derived one, and the remedy is still the flag's.""" - stderr = run_tool(tmp_path, tool, "North_Wind", "--id=") - assert refusal( - tool, - f"{derived(tool, 'empty')} is 'north_wind', which must match " - f"{PATTERN}", - derived_remedy(tool)) in stderr - assert "not given" not in stderr +def test_an_empty_id_is_a_passed_id_refused_by_name(tmp_path, tool): + """#911 (U156): `--id ''` was read by its truth, so it planned the + derived id and exited 0, and a script's `--id "$ID"` with `ID` unset + went through without a word. It is a passed value now, held to the + id's rule and refused by name, before `checked_value` would call it + `unsafe-value`.""" + stderr = run_tool(tmp_path, tool, "NorthWind", "--id=") + assert_named_as_passed(tool, stderr, "") + assert "empty, so" not in stderr + assert "unsafe-value" not in stderr @pytest.mark.parametrize("tool", TOOLS) -def test_a_passed_id_equal_to_the_derived_one_is_still_named_as_passed( - tmp_path, tool): - """Passed means typed, not "differs from the name lowercased".""" - stderr = run_tool(tmp_path, tool, "North_Wind", "--id", "north_wind") - assert_named_as_passed(tool, stderr, "north_wind") +@pytest.mark.parametrize("value", + ["Foo_Bar", "foo_bar", "-north", "north wind"]) +def test_a_passed_bad_id_is_told_what_an_id_may_hold(tmp_path, tool, value): + """#911 (U158): `foo_bar` is explicit and lowercase already, and was + told to be both; its fault is the underscore. Each row is refused as + the id it is not, a leading hyphen and a space included, which + `checked_value` refused as `unsafe-value` before the id's own rule was + asked.""" + stderr = run_tool(tmp_path, tool, "NorthWind", f"--id={value}") + assert_named_as_passed(tool, stderr, value) + assert "unsafe-value" not in stderr @pytest.mark.parametrize("tool", TOOLS) -def test_a_passed_bad_id_keeps_its_sentence_and_its_remedy(tmp_path, tool): - stderr = run_tool(tmp_path, tool, "NorthWind", "--id", "Foo_Bar") - assert_named_as_passed(tool, stderr, "Foo_Bar") +def test_an_id_the_id_rule_lets_through_still_meets_the_alphabet( + tmp_path, tool): + """`PROJECT_ID_RE.match` takes a trailing line break (`$` matches before + one), so the id is still held to `checked_value`'s alphabet after its + own rule, and refused as before.""" + stderr = run_tool(tmp_path, tool, "NorthWind", "--id=north" + chr(10)) + assert "REFUSED unsafe-value: --id is 'north" in stderr + assert TOOLS[tool]["code"] not in stderr + + +def test_the_passed_remedy_is_the_id_rule_in_words(): + """The words say what `PROJECT_ID_RE` holds: lowercase letters, digits + and hyphens, the first a letter or a digit.""" + assert PATTERN == "^[a-z0-9][a-z0-9-]*$" + for good in ("a", "0", "north-wind", "9-lives", "a--b"): + assert PROJECT_ID_RE.match(good), good + for bad in ("", "-a", "A", "a_b", "a.b", "a b"): + assert not PROJECT_ID_RE.match(bad), bad def test_the_characters_a_derived_id_is_refused_for(): @@ -158,17 +199,94 @@ def test_the_characters_a_derived_id_is_refused_for(): @pytest.mark.parametrize("tool", TOOLS) +@pytest.mark.parametrize("passed", ["north-wind", "north_wind", ""]) @pytest.mark.parametrize("char", REFUSED_IN_AN_ID) def test_a_name_whose_derived_id_is_refused_is_refused_by_the_naming_policy( - tmp_path, tool, char): + tmp_path, tool, char, passed): """Why a derived id's remedy is its flag's and not `--id`'s: the naming policy reads the name itself and refuses every one whose lowercase is no id, so a `--id` that is an id only meets that refusal. Run with one, so this is the run the old remedy led to. And the refusal states the rule - the derived remedy names, in the same words.""" + the derived remedy names, in the same words. + + A REFUSED NAME SPEAKS FIRST, WHATEVER THE ID (#911, U159): with a + passed id that is no id either (`north_wind`, `''`) the id was refused + first, and its remedy's retype met this refusal; now this one comes + first and the id's does not appear.""" name = f"North{char}Wind" - stderr = run_tool(tmp_path, tool, name, "--id", "north-wind") + stderr = run_tool(tmp_path, tool, name, f"--id={passed}") assert (f"REFUSED naming-unclassified: {name!r} matches no family in the " "naming policy") in stderr - assert NAME_RULE in stderr + assert TOOLS[tool]["rule"] in stderr assert TOOLS[tool]["code"] not in stderr + assert PASSED_REMEDY not in stderr + + +@pytest.mark.parametrize("name, form", [("Foo-Install", "install"), + ("Atlas-spec", "project-leg/spec"), + ("Atlas-code", "project-leg/code")]) +def test_a_form_no_assembly_root_has_speaks_before_a_passed_id(tmp_path, name, + form): + """The scaffold's half (#911): a `--project` the policy holds in no form + an assembly root may have, with nothing declared, is refused as + `naming-role-mismatch` before a passed id that is no id, as it is after + a good one; no `--id` mends it. `Foo-Install` is decided by its + characters alone. `Atlas-spec` and `Atlas-code` are a leg's form with a + pin or without, and were refused for the id first, whose retype then + met this refusal (#972's review, M1).""" + for passed in ("foo_bar", "foo-bar"): + stderr = run_tool(tmp_path / passed, "scaffold", name, + f"--id={passed}") + assert (f"REFUSED naming-role-mismatch: {name!r} classifies as " + f"{form}, not as the 'assembly' form of a project leg" + ) in stderr + assert "scaffold-bad-id" not in stderr + + +@pytest.mark.parametrize("name", ["openGlass", "MedxGlass"]) +def test_a_name_the_scaffold_takes_leaves_the_passed_id_refused(tmp_path, + name): + """THE CONTROL: a neutral product may be an assembly root, and a + descendant form's role turns on what the project declares, which is + read later; neither is refused early, so the passed id is.""" + stderr = run_tool(tmp_path, "scaffold", name, "--id=foo_bar") + assert_named_as_passed("scaffold", stderr, "foo_bar") + assert "naming-" not in stderr + + +@pytest.mark.parametrize("name, refused", [ + pytest.param("openGlass", "REFUSED naming-not-a-family: 'openGlass' " + "classifies as neutral-product", id="openGlass"), + pytest.param("NUL", "REFUSED family-bad-holder-name: --family is 'NUL', " + "which names the holder and the folders `init` makes for " + "it, and ", id="NUL")]) +def test_a_name_no_holder_can_have_speaks_before_a_passed_id(tmp_path, name, + refused): + """`init` holds `--family` to the holder's form whole, so a name that + classifies as another form, `openGlass`, is refused as + `naming-not-a-family` before a passed id that is no id (#911); and a + name no Windows disk can make a folder of, `NUL`, as + `family-bad-holder-name`, which the passed id spoke before (#972's + review, S1). No `--id` mends either.""" + stderr = run_tool(tmp_path, "family", name, "--id=foo_bar") + assert refused in stderr + assert "family-bad-id" not in stderr + + +def test_init_s_derived_remedy_rules_out_what_naming_not_a_family_refuses( + tmp_path): + """#911 (U162): `--family open_glass` was told to change `--family` to + one CamelCase token, and `openGlass`, which is one, was then refused as + `naming-not-a-family`. The remedy names the two forms no holder can be + declared as, in that refusal's own words, and the retype it ruled out + is still refused there.""" + stderr = run_tool(tmp_path, "family", "open_glass") + assert refusal( + "family", + f"{derived('family', 'not given')} is 'open_glass', which must match " + f"{PATTERN}", + derived_remedy("family")) in stderr + assert NOT_A_HOLDER in derived_remedy("family") + retyped = run_tool(tmp_path / "retyped", "family", "openGlass") + assert "REFUSED naming-not-a-family: 'openGlass' " in retyped + assert "An `open` or `-Install` name" in retyped diff --git a/tests/test_dry_run_parity.py b/tests/test_dry_run_parity.py index 46281ef9..b03f8c8b 100644 --- a/tests/test_dry_run_parity.py +++ b/tests/test_dry_run_parity.py @@ -25,6 +25,12 @@ 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". +- #930 (#459's U129). An EXISTING EMPTY working tree or holder this user + cannot LIST (mode 000, 100, 200 or 300) was refused as `cannot be read: + Permission denied`, and told the held-tree remedy, "choose an empty + --work-dir. There is no --force", a cause the run cannot see: it cannot + tell an empty folder from a live tree there. It is told the permission + now, and the flag that chooses another folder. THE CURE. One question, asked by both runs before anything is made: every folder the run makes (`shape_materialize.folder_cannot_be_made`), each @@ -50,6 +56,7 @@ from __future__ import annotations +import errno import os import stat import subprocess @@ -702,16 +709,98 @@ def test_a_working_tree_this_user_cannot_write_in_is_named_last( 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.""" + """`target_remediation`: a permissions sentence for each of the two + answers that are about this user's permissions, and the tool's own + sentence, unchanged, for every other: a tree that holds something, a + file, and a path that is no folder to use (#930).""" 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("cannot be read: Permission denied", "holds", "--x") == ( + unreadable_remediation("--x")) + for why in ("is not empty", "is a file, not a folder", "is not a folder", + "cannot be used as a folder: Permission denied"): assert pick(why, "holds", "--x") == "holds" +# --- #930: an EMPTY working tree this user cannot list ----------------------- + +#: The modes #930 measured, as `(name, mode)`: none has `r`, so the folder +#: cannot be listed, whatever its `w` and `x`, and whether it is empty +#: cannot be asked. +CANNOT_LIST = {"000": 0o000, "100": 0o100, "200": 0o200, "300": 0o300} + + +def unreadable_remediation(flag: str) -> str: + """What the refusal of a working tree this user cannot list says to do + (#930): the permission, and that the run cannot tell whether it holds a + tree, not "choose an empty folder", which is about a tree that holds + something.""" + return ("Remediation: nothing was created, and `--dry-run` refuses the " + "same. This user cannot list that folder, so whether it holds a " + "tree cannot be asked: give this user read, write and search " + f"permission on it, or choose another {flag}, and run this " + "again.") + + +def cannot_list_says() -> str: + """The system's own words for listing a folder this user may not read, + as `target_in_the_way` quotes them: EACCES's `strerror` (L2-67).""" + return os.strerror(errno.EACCES) + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real-run"]) +@pytest.mark.parametrize("mode", list(CANNOT_LIST)) +@pytest.mark.parametrize("tool", list(TREE_AT)) +def test_an_empty_working_tree_this_user_cannot_list_is_told_the_permission( + tmp_path, unwritable_folder, tool, mode, dry_run): + """#930, one row per tool and mode, by the dry run and the real run: an + existing EMPTY working tree (the scaffold's root, `init`'s holder under + `--into`) this user cannot list is refused by name before anything is + made, and told to give this user read, write and search permission on + it or to choose another folder by the flag the person met it by. On main + 01a2b016 each was told "choose an empty --work-dir. There is no + --force" or `init`'s "if that IS the holder, it is already a family".""" + put, lands, code, flag = TREE_AT[tool] + tree = unwritable_folder(put, CANNOT_LIST[mode]) + 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 " + f"and cannot be read: {cannot_list_says()}") + assert remediation_line(result) == unreadable_remediation(flag) + assert HOLDS_REMEDIATION[tool] not in result.stderr + 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_unlistable_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, as L2-60's + does: with `--work-dir` there is no family folder.""" + tree = unwritable_folder("wd/Orbit", CANNOT_LIST["300"]) + 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 cannot be read: " + f"{cannot_list_says()}") + assert remediation_line(result) == unreadable_remediation("--work-dir") + + +def test_a_working_tree_this_user_cannot_list_is_said_to_be_unread( + unwritable_folder): + """`target_in_the_way`: a folder with no `r` is `cannot be read`, in the + system's words, whether it holds anything or not, because listing it is + the question that fails; `target_remediation` keys on those words.""" + ask = shape_materialize.target_in_the_way + for name, mode in CANNOT_LIST.items(): + for holding in (None, "f"): + folder = unwritable_folder(f"{name}-{holding}", mode, + holding=holding) + assert ask(folder) == (f"{shape_materialize.CANNOT_READ}: " + f"{cannot_list_says()}") + + # --- 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( diff --git a/tests/test_github_limits_init_check_org.py b/tests/test_github_limits_init_check_org.py index ec19c068..f7a9076b 100644 --- a/tests/test_github_limits_init_check_org.py +++ b/tests/test_github_limits_init_check_org.py @@ -288,12 +288,30 @@ def test_init_names_a_derived_id_as_derived_and_mends_it_with_an_id( "family folder, stays as it is.\n") -def test_init_names_an_empty_id_as_empty(family, only_the_branch_question, - capsys, tmp_path): +def test_init_refuses_an_empty_id_as_passed_before_its_topic( + family, only_the_branch_question, capsys, tmp_path): + """#911: `--id ''` fell back to `--family` lowercased and was named + `--id (empty, so --family lowercased)` here. It is a passed value now, + refused by name as `family-bad-id` before GitHub's limits are asked.""" err = refused_init(family, capsys, tmp_path, "--family", a_name(ID_MAX + 1), "--id", "") - assert err.startswith(f"{FAMILY_TOPIC_TOO_LONG}: --id (empty, so --family " - "lowercased) is "), err + assert err.startswith("REFUSED family-bad-id: --id '' must match "), err + assert "empty, so" not in err + assert FAMILY_TOPIC_TOO_LONG not in err + + +def test_init_refuses_a_long_family_before_a_passed_bad_id( + family, only_the_branch_question, capsys, tmp_path): + """#972's review, S1: a `--family` longer than GitHub keeps a + repository's name was refused as `family-bad-id` for a passed id that is + no id, and the retype then met `family-name-too-long`, which no `--id` + mends. The name speaks first, as `scaffold-name-too-long` does in the + scaffold, with no process started.""" + err = refused_init(family, capsys, tmp_path, "--family", + a_name(NAME_MAX + 5), "--id", "foo_bar") + assert err.startswith(f"{FAMILY_NAME_TOO_LONG}: --family is " + "Abbbbbbbbbbb...bbbbbbbbbbbb, 105 characters, "), err + assert "family-bad-id" not in err def test_init_names_a_passed_id_as_passed(family, only_the_branch_question, diff --git a/tests/test_github_name_limits.py b/tests/test_github_name_limits.py index ca60c58b..197c848e 100644 --- a/tests/test_github_name_limits.py +++ b/tests/test_github_name_limits.py @@ -379,13 +379,18 @@ def test_a_derived_id_is_named_as_derived_and_mended_with_an_id( "which names the three repositories, stays as it is.\n"), err -def test_an_empty_id_is_named_as_empty(scaffold, no_process, capsys): - """`--id ''` falls back to `--project` lowercased, as `_identity` reads - it, and is named as `_refuse_bad_id` names it: passed, and empty.""" +def test_an_empty_id_is_refused_as_passed_before_its_topic( + scaffold, no_process, capsys): + """#911: `--id ''` fell back to `--project` lowercased, as `_identity` + read it by its truth, and this refusal named it `--id (empty, so + --project lowercased)`. It is a passed value now, refused by name as + `scaffold-bad-id` before GitHub's limits are asked, with no process + started, and never named as derived.""" err = refused_plan(scaffold, capsys, "--project", a_name(ID_MAX + 1), "--id", "") - assert err.startswith( - f"{TOPIC_TOO_LONG}: --id (empty, so --project lowercased) is "), err + assert err.startswith("REFUSED scaffold-bad-id: --id '' must match "), err + assert "empty, so" not in err + assert TOPIC_TOO_LONG not in err def test_a_passed_id_is_named_as_passed(scaffold, no_process, capsys): diff --git a/tests/test_scaffold_e2e.py b/tests/test_scaffold_e2e.py index 81911576..ccf81ced 100644 --- a/tests/test_scaffold_e2e.py +++ b/tests/test_scaffold_e2e.py @@ -245,11 +245,14 @@ def test_the_scaffold_refuses_with_no_elector(tmp_path): # run_script, and the fixtures set GIT_AUTHOR_NAME there; the scaffold # takes that per-invocation identity as an elector before the config, so # the no-elector path is reachable only with BOTH blanked (empty string - # falls through to the config, which is /dev/null here). + # falls through to the config, which is /dev/null here). And with NO + # `--elected-by`: a `--elected-by ''` is the flag's own answer since #911, + # refused by name (`test_scaffold_init_empty_values.py`), and the + # no-elector refusal is about a flag that was not passed. env = {"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull, "GIT_AUTHOR_NAME": "", "GIT_COMMITTER_NAME": ""} result = run_script(SCAFFOLD, "--org", ORG, "--project", "Northwind", - "--elected-by", "", "--dry-run", + "--dry-run", "--local-remote-dir", str(remotes), "--work-dir", str(tmp_path / "work"), env=env) assert result.returncode == 2, result.stdout diff --git a/tests/test_scaffold_init_empty_values.py b/tests/test_scaffold_init_empty_values.py new file mode 100644 index 00000000..b07d10ee --- /dev/null +++ b/tests/test_scaffold_init_empty_values.py @@ -0,0 +1,293 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`scaffold-project.py` and `family.py init` refuse a flag passed empty by +name, and never read it as a flag not given (#911, #459's U156, U157 and +U160). + +WHAT WAS WRONG. Each tool read the values it fills in by itself by their +truth (`args.id or project.lower()`), so the scaffold's `--id ''`, `--name +''`, `--elected-on ''`, `--reference ''` and `--elected-by ''`, and `init`'s +`--id ''`, `--name ''`, `--created-on ''` and `--created-by ''`, each planned +its default and exited 0: a script's `--id "$ID"` with `ID` unset got the +derived id without a word, and an empty elector or creator became +GIT_AUTHOR_NAME. With no identity at all, `--elected-by ''` was refused as +`scaffold-no-elector: no --elected-by ...`, of a flag that was passed, and +`--created-by ''` as `family-no-creator` alike. + +THE CURE, as `adopt-project.py plan` reads its own flags since #699: each is +read by `is not None`, and an empty one meets its flag's own rule and is +refused there by name, value as typed. `--id ''` is a passed id that is no +id (`scaffold-bad-id`, `family-bad-id`); `--elected-on ''` is no date +(`election-date-malformed`); and the free-text values, `--created-on ''` +among them, are refused by the new project's or holder's own validator +(`scaffold-manifest-invalid`, `family-manifest-invalid`), which names the +flag as passed, never "(not given, so ...)". The fallbacks answer only +where the flag is not given. + +AND THE FOLDER FLAGS (#972's review, S2). `--work-dir` and +`--local-remote-dir` in both tools, and `init`'s `--into`, were parsed as a +`Path`, and `Path("")` is `.`: passed empty, each was read as the directory +the run is in, without a word, and from the root of a checkout of +openRepoShape `init --into ''` planned the holder inside it. Each is now +refused by name, `-no-path`, as `--pin-source ''` is +(`pin-source-no-path`), by the dry run and the real run alike, before +anything is made. A typed `.` is still this directory, and a flag not given +keeps its default. + +Every run is into `--local-remote-dir`, which never calls `gh`; a row that +passes it empty would read it as `.`, a local folder too. Every run is a +`--dry-run`, and each folder row is also run for real. +""" + +from __future__ import annotations + +import os + +import pytest + +from conftest import ORG, REPO, SCAFFOLD, run_script + +FAMILY = REPO / "scripts" / "family.py" + +#: What `--dry-run` prints when it goes all the way. +DRY_RUN_DONE = "--dry-run: nothing was created." + +#: An identity the environment offers, which an empty flag must not take. +FALLBACK = "Fallback Human" + +#: The refusals each tool ends on, by the flag passed empty: the start of +#: its `REFUSED` line, which names the flag and the value as typed. +SCAFFOLD_VALIDATOR = "which the project's own validate-manifest.py refuses: " +INIT_VALIDATOR = "which the holder's own validate-family.py refuses: " +ID_RULE = "must match ^[a-z0-9][a-z0-9-]*$" +EMPTY_ROWS = { + "scaffold": { + "--id": f"REFUSED scaffold-bad-id: --id '' {ID_RULE}", + "--name": "REFUSED scaffold-manifest-invalid: --name is '', " + f"{SCAFFOLD_VALIDATOR}manifest-name: ", + "--elected-on": "REFUSED election-date-malformed: --elected-on '' is " + "not a YYYY-MM-DD date", + "--reference": "REFUSED scaffold-manifest-invalid: --reference is '', " + f"{SCAFFOLD_VALIDATOR}manifest-reference: ", + "--elected-by": "REFUSED scaffold-manifest-invalid: --elected-by is " + f"'', {SCAFFOLD_VALIDATOR}manifest-elected-by: ", + }, + "family": { + "--id": f"REFUSED family-bad-id: --id '' {ID_RULE}", + "--name": "REFUSED family-manifest-invalid: --name is '', " + f"{INIT_VALIDATOR}family-name: ", + "--created-on": "REFUSED family-manifest-invalid: --created-on is '', " + f"{INIT_VALIDATOR}family-created-on: ", + "--created-by": "REFUSED family-manifest-invalid: --created-by is '', " + f"{INIT_VALIDATOR}family-created-by: ", + }, +} + +#: Per tool: the command, its name flag and name, and the flags naming who +#: and when, which a row leaves out when it passes that flag empty. +TOOLS = { + "scaffold": {"command": (SCAFFOLD,), "name": ("--project", "NorthWind"), + "who": "--elected-by", "when": "--elected-on"}, + "family": {"command": (FAMILY, "init"), "name": ("--family", "NorthWind"), + "who": "--created-by", "when": "--created-on"}, +} + +#: No identity at all: no GIT_AUTHOR_NAME, and no `user.name` in a global or +#: system config git can read. +NO_IDENTITY = {"GIT_AUTHOR_NAME": "", "GIT_COMMITTER_NAME": "", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull} + + +def dry_run(tmp_path, tool: str, *extra: str, env: dict | None = None, + who: bool = True, when: bool = True): + """One `--dry-run` of `tool` for `NorthWind`, with `extra`, into a local + remote. `who` and `when` pass the tool's own creator and date flags; + a row that passes one of them empty leaves the default out.""" + spec = TOOLS[tool] + remotes = tmp_path / "remotes" + result = run_script( + *spec["command"], "--org", ORG, *spec["name"], + *((spec["who"], "Test Human") if who else ()), + *((spec["when"], "2026-09-02") if when else ()), + *extra, "--dry-run", + "--local-remote-dir", str(remotes), + "--work-dir", str(tmp_path / "work"), + env={"GIT_AUTHOR_NAME": FALLBACK, **(env or {})}) + assert not remotes.exists() + assert not (tmp_path / "work").exists() + return result + + +def refused_line(result) -> str: + """The `REFUSED` line a run ended on.""" + return next((line for line in result.stderr.splitlines() + if line.startswith("REFUSED ")), "") + + +def assert_refused_by_name(result, starts: str) -> None: + """Exit 2, before the plan, on the refusal `starts`: the flag named as + passed, never as a value the run filled in.""" + assert result.returncode == 2, result.stdout + result.stderr + assert refused_line(result).startswith(starts), result.stderr + assert "not given" not in result.stderr + assert "Traceback" not in result.stderr + assert result.stdout == "", result.stdout + + +@pytest.mark.parametrize("tool, flag", [ + pytest.param(tool, flag, id=f"{tool}{flag}") + for tool, rows in EMPTY_ROWS.items() for flag in rows]) +def test_a_flag_passed_empty_is_refused_by_name(tmp_path, tool, flag): + """One row per flag (#911): the flag passed empty is refused by name + before the plan, and the environment's identity, which the run takes + where the flag is not given, is not taken for it.""" + spec = TOOLS[tool] + result = dry_run(tmp_path, tool, f"{flag}=", + who=flag != spec["who"], when=flag != spec["when"]) + assert_refused_by_name(result, EMPTY_ROWS[tool][flag]) + assert FALLBACK not in result.stderr + + +@pytest.mark.parametrize("tool", list(TOOLS)) +def test_the_flags_not_given_still_take_their_defaults(tmp_path, tool): + """THE CONTROL: with none of these flags, each tool plans its defaults, + the creator from GIT_AUTHOR_NAME among them, and exits 0. What the rows + above refuse is the empty value, not the default.""" + result = dry_run(tmp_path, tool, who=False, when=False) + assert result.returncode == 0, result.stdout + result.stderr + assert DRY_RUN_DONE in result.stdout + assert f"{FALLBACK} on " in result.stdout + assert "(northwind)" in result.stdout + + +@pytest.mark.parametrize("tool, refusal", [ + pytest.param("scaffold", "REFUSED scaffold-no-elector: no --elected-by, " + "no GIT_AUTHOR_NAME and no `git config user.name`.", + id="scaffold"), + pytest.param("family", "REFUSED family-no-creator: no --created-by, no " + "GIT_AUTHOR_NAME and no `git config user.name`.", + id="family")]) +def test_with_no_identity_an_empty_creator_is_named_as_passed( + tmp_path, tool, refusal): + """#911: with no identity at all, `--elected-by ''` was refused as + "no --elected-by", of a flag that was passed (`--created-by ''` + alike). It is named as passed now, by the validator; the no-elector + refusal is still what a run with no flag and no identity gets.""" + who = TOOLS[tool]["who"] + passed = dry_run(tmp_path / "passed", tool, f"{who}=", who=False, + env=NO_IDENTITY) + assert_refused_by_name(passed, EMPTY_ROWS[tool][who]) + assert f"no {who}" not in passed.stderr + omitted = dry_run(tmp_path / "omitted", tool, who=False, env=NO_IDENTITY) + assert omitted.returncode == 2, omitted.stdout + omitted.stderr + assert refused_line(omitted).startswith(refusal), omitted.stderr + + +# --- the folder flags (#972's review, S2) ----------------------------------- + +#: Each folder flag a tool takes, by `(tool, flag)`: the code it is refused +#: as when passed empty, what its remedy says the folder is for, and what it +#: says the run does with no flag at all, in that tool's words. +FOLDER_ROWS = { + ("scaffold", "--work-dir"): ( + "work-dir-no-path", "the folder the working trees are built in", + "they are built in a temporary directory"), + ("scaffold", "--local-remote-dir"): ( + "local-remote-dir-no-path", + "the folder the bare repositories are made in", + "they are created on GitHub"), + ("family", "--into"): ( + "into-no-path", "the parent folder the family folder is made in", + "it is made in the directory you stand in, which is refused inside " + "a checkout of openRepoShape"), + ("family", "--work-dir"): ( + "work-dir-no-path", "the folder the holder is made in", + "the holder lands in a family folder, by --into"), + ("family", "--local-remote-dir"): ( + "local-remote-dir-no-path", + "the folder the holder's bare repository is made in", + "it is created on GitHub"), +} +FOLDER_PARAMS = [pytest.param(tool, flag, id=f"{tool}{flag}") + for tool, flag in FOLDER_ROWS] + + +def folder_run(tmp_path, tool: str, flag: str, value: str | None, *, + dry_run: bool = True): + """One run of `tool` for `NorthWind` from `tmp_path/here`, an empty + folder, with `flag` given `value`, or left out when `value` is None. + The other folder flags name folders under `tmp_path` that do not exist: + `--local-remote-dir`, and `--work-dir` but beside `init`'s `--into`, + which it excludes.""" + spec = TOOLS[tool] + here = tmp_path / "here" + here.mkdir(exist_ok=True) + folders = {"--local-remote-dir": tmp_path / "remotes", + "--work-dir": tmp_path / "work"} + if flag == "--into": + del folders["--work-dir"] + folders.pop(flag, None) + if value is not None: + folders[flag] = value + return run_script( + *spec["command"], "--org", ORG, *spec["name"], + spec["who"], "Test Human", spec["when"], "2026-09-02", + *(f"{name}={folder}" for name, folder in folders.items()), + *(["--dry-run"] if dry_run else []), + cwd=here, env={"GIT_AUTHOR_NAME": FALLBACK}) + + +def everything_under(root) -> list: + """Every path under `root`, so a test sees whether a run made one.""" + return sorted(path.relative_to(root).as_posix() + for path in root.rglob("*")) + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real-run"]) +@pytest.mark.parametrize("tool, flag", FOLDER_PARAMS) +def test_a_folder_flag_passed_empty_is_refused_by_name(tmp_path, tool, flag, + dry_run): + """One row per folder flag per tool, by the dry run and the real run: + passed empty, the flag is refused by name before anything is made, and + told the three ways on, a path, `.`, or no flag. Read as a `Path` it was + `.`, so each built, made or landed in the directory the run stood in.""" + code, names, absent = FOLDER_ROWS[tool, flag] + (tmp_path / "here").mkdir() + before = everything_under(tmp_path) + result = folder_run(tmp_path, tool, flag, "", dry_run=dry_run) + said = (f"REFUSED {code}: `{flag} ''` names no path. An empty path would " + "be read as the directory this runs in.") + assert_refused_by_name(result, said) + assert refused_line(result) == said, result.stderr + assert (f"\nRemediation: nothing was created, and `--dry-run` refuses the " + f"same. Give {names} (`{flag} `), or `.` to mean this " + f"directory; with no {flag}, {absent}.\n") in result.stderr + assert everything_under(tmp_path) == before, "the run made something" + + +@pytest.mark.parametrize("tool, flag", FOLDER_PARAMS) +def test_a_folder_flag_typed_as_a_dot_is_still_this_directory(tmp_path, tool, + flag): + """THE CONTROL: the remedy's `.` is taken, as it always was, and the + dry run plans the folder it names and makes nothing.""" + result = folder_run(tmp_path, tool, flag, ".") + assert result.returncode == 0, result.stdout + result.stderr + assert DRY_RUN_DONE in result.stdout + assert everything_under(tmp_path) == ["here"] + + +@pytest.mark.parametrize("tool, flag", [ + pytest.param(tool, flag, id=f"{tool}{flag}") + for tool, flag in FOLDER_ROWS if flag != "--local-remote-dir"]) +def test_a_folder_flag_not_given_keeps_its_default(tmp_path, tool, flag): + """THE CONTROL: with no `--work-dir` the scaffold builds in a temporary + directory, and with neither `--into` nor `--work-dir` `init` lands where + the person stands, as before. (No `--local-remote-dir` is GitHub, which + the tests of a dry run against `gh` hold.)""" + result = folder_run(tmp_path, tool, flag, None) + assert result.returncode == 0, result.stdout + result.stderr + assert DRY_RUN_DONE in result.stdout + if tool == "family": + holder = (tmp_path / "here" / "NorthWind" / "NorthWind").resolve() + assert f"landing {holder}" in result.stdout, result.stdout + assert everything_under(tmp_path) == ["here"]