From c3821fb0f95b331689717445eac1f3a27e07dcf5 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 19 Oct 2026 21:36:40 +0000 Subject: [PATCH 1/3] scaffold-project.py, family.py and shape_materialize.py: one function asks both tools whether a name is one a Windows disk can make a folder of, so the scaffold refuses `--project NUL`, `Con` or `com1` as `scaffold-bad-project-name` before anything exists, under `--dry-run` alike, as `init` refuses the same `--family` (#989) `shape_materialize.refuse_an_unholdable_name` is `family.py`'s `_refuse_an_unholdable_holder_name` question, lifted: the leg path rule (`_unmountable_reason`) asked of a name of one segment, with the caller's code, flag, words and remediation. `init` asks it as before, in the same words. The scaffold asks it of `--project` after `_refuse_bad_id` and before a passed id's alphabet and `_declaration`, and, for a passed id that is no id, inside `_refuse_bad_id`: the naming refusal, then this one, then the id's sentence (#972's order). A device-shaped name the naming policy refuses too (`NUL.git`) is refused as the policy refuses it, whatever the id. `Nullable`, `Console` and `Auxo` still scaffold. Co-Authored-By: Claude Opus 5.5 --- scaffold-project.py | 106 ++++++++++++---- scripts/family.py | 30 ++--- scripts/shape_materialize.py | 32 +++++ tests/test_derived_id_refusals.py | 15 +++ tests/test_scaffold_device_names.py | 187 ++++++++++++++++++++++++++++ 5 files changed, 334 insertions(+), 36 deletions(-) create mode 100644 tests/test_scaffold_device_names.py diff --git a/scaffold-project.py b/scaffold-project.py index 2c23d9e8..c0c6b8f2 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -106,6 +106,9 @@ ) # The leg-path rule `adopt-project.py` asks of its own paths (#179). from shape_materialize import refuse_bad_leg_paths # noqa: E402 +# And its question of one segment, asked of `--project`: the one function +# `family.py init` asks of `--family` (#688, #989). +from shape_materialize import refuse_an_unholdable_name # noqa: E402 # The root's visibility and the Free-plan secrets note, asked as `family.py` # asks them of its holder (#660). from shape_materialize import ( # noqa: E402 @@ -972,31 +975,17 @@ def _refuse_bad_id(project_id: str, given: str | None, policy, 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.""" + root (`_refuse_a_name_no_root_can_have`), and then one a Windows disk + can make a folder of (`_refuse_an_unholdable_project_name`, #989): + `--project NUL --id foo_bar` is told the name, which no `--id` mends + either. 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 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") + _refuse_a_name_no_root_can_have(policy, project) + _refuse_an_unholdable_project_name(policy, project) raise Refusal("scaffold-bad-id", f"--id {project_id!r} must match {pattern}", PASSED_ID_REMEDIATION) @@ -1006,6 +995,77 @@ def _refuse_bad_id(project_id: str, given: str | None, policy, DERIVED_ID_REMEDIATION) +def _refuse_a_name_no_root_can_have(policy, project: str) -> None: + """`--project` is a name the naming policy holds as an assembly root, or + this refuses it as `_check_one_name` refuses the assembly root's name, + which `_check_declared_names` asks later, after `_declaration`. Asked + early only where another refusal would speak first otherwise: a passed + id that is no id (`_refuse_bad_id`, #911), or a name no Windows disk can + make a folder of (`_refuse_an_unholdable_project_name`, #989). + + 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.""" + found = policy.classify(project, "assembly") + if found is None: + raise _unclassified(project) + if not accepts_role(found, "assembly"): + raise _role_mismatch(project, found, "assembly") + + +#: What `scaffold-bad-project-name` is told to do (#989), in the words +#: `family.py init` gives `family-bad-holder-name` (#688), of what +#: `--project` names. +PROJECT_NAME_REMEDIATION = ( + "Remediation: nothing was created, and `--dry-run` refuses the same. " + "Choose another --project. It names the assembly root: its repository, " + "the folder it is cloned into, its working tree and, under " + "--local-remote-dir, its bare repository `.git`; and a " + "root is cloned on Windows too, where no file or folder can be called a " + "device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`, " + "in any case, with or without an extension), and a name ending in a dot " + "or a space is made under another name. There is no --force.") + + +def _refuse_an_unholdable_project_name(policy, project: str) -> None: + """`scaffold-bad-project-name`: a `--project` no Windows disk can make a + folder of (#989), before `_declaration`, the first place a `--pin` can + reach `gh`, so before anything exists and under `--dry-run` alike. + + `--project NUL` and `Con`, each one CamelCase token, which is all the + naming policy asks, made the three repositories, the working trees and, + under `--local-remote-dir`, the bare `NUL.git`: a root no Windows disk + can clone, into a folder of its name. `family.py init` refused the same + `--family` as `family-bad-holder-name` (#688); the question is the one + function both ask (`shape_materialize.refuse_an_unholdable_name`). The + legs' names are not asked: a device name is the whole name, and + `NUL-spec` is a folder Windows makes. + + THE NAMING REFUSAL SPEAKS FIRST, as `init` asks `_classify_family_name` + before its own: a name this refuses that the policy refuses too + (`NUL.git`, `Nul.`) is refused as the policy refuses it, whatever the + id, and only a name the policy holds as an assembly root is told it is + a device name.""" + try: + refuse_an_unholdable_name( + "scaffold-bad-project-name", "--project", project, + names="the assembly root and the folders the scaffold makes " + "for it", + remediation=PROJECT_NAME_REMEDIATION) + except Refusal: + _refuse_a_name_no_root_can_have(policy, project) + raise + + #: How `scaffold-topic-too-long` and `scaffold-org-too-long` are told to go #: on (#838). A `--id` the person passed is theirs to shorten. One #: `_identity` derived is `--project` lowercased, and an explicit `--id` is @@ -2248,6 +2308,10 @@ def _scaffold(args) -> int: _refuse_an_unwritable_clone_url(local, urls["assembly"]) policy = NamingPolicy.load(SHAPE_ROOT / "contracts" / "repository-naming.yaml") _refuse_bad_id(project_id, args.id, policy, project) + # A NAME NO WINDOWS DISK CAN MAKE A FOLDER OF (#989), as `init` asks it + # of `--family`: before `_declaration` and before a passed id's alphabet, + # so the name speaks before the id, as it does in `_refuse_bad_id`. + _refuse_an_unholdable_project_name(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. diff --git a/scripts/family.py b/scripts/family.py index 3d743289..879a2bfc 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -216,8 +216,9 @@ from shape_materialize import unwritable_reason # noqa: E402 # Whether `--family` names a folder a Windows disk can make (#688): the rule # the scaffold and `adopt-project.py` hold a leg's mount path to, asked of a -# path of one segment. -from shape_materialize import _unmountable_reason # noqa: E402 +# path of one segment, by the one function the scaffold asks of `--project` +# too (#989). +from shape_materialize import refuse_an_unholdable_name # noqa: E402 # How a refusal quotes the words git echoes of a value it refused (#688): as # a path is quoted (#610), as it is when printable and as Python's `repr` # spells it when it is not. @@ -1027,20 +1028,19 @@ def _refuse_an_unholdable_holder_name(family: str) -> None: THE QUESTION IS THE LEG PATH RULE'S (`_unmountable_reason`), the one the scaffold and `adopt-project.py` ask of a leg's mount path, asked of a - path of one segment, in its own words. It is asked after - `_classify_family_name` on purpose: the policy's token holds no `.`, no - `~` and no space, so a device name is the only answer the rule can give - here, and the names it also refuses (a trailing dot or space, `.git`, - `git~1`, `.gitmodules`) are refused before it, as `unsafe-value`, - `family-bad-id` or `naming-unclassified`, as they always were. + path of one segment, in its own words, by + `shape_materialize.refuse_an_unholdable_name`, which the scaffold asks of + `--project` too (#989). It is asked after `_classify_family_name` on + purpose: the policy's token holds no `.`, no `~` and no space, so a + device name is the only answer the rule can give here, and the names it + also refuses (a trailing dot or space, `.git`, `git~1`, `.gitmodules`) + are refused before it, as `unsafe-value`, `family-bad-id` or + `naming-unclassified`, as they always were. """ - reason = _unmountable_reason(family) - if reason: - raise Refusal( - "family-bad-holder-name", - f"--family is {family!r}, which names the holder and the folders " - f"`init` makes for it, and {reason}.", - HOLDER_NAME_REMEDIATION) + refuse_an_unholdable_name( + "family-bad-holder-name", "--family", family, + names="the holder and the folders `init` makes for it", + remediation=HOLDER_NAME_REMEDIATION) def _read_member_project(path: Path, project: str, repository: str, diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 3faa427d..743f6ef9 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -4618,6 +4618,38 @@ def _unmountable_reason(text: str) -> str | None: return None +def refuse_an_unholdable_name(refusal: str, flag: str, name: str, *, + names: str, remediation: str) -> None: + """`name`, typed as `flag`, is a name a Windows disk can make a folder + of, or this refuses it as `refusal`: before anything exists, under + `--dry-run` alike, and with no process started. + + ONE QUESTION FOR EVERY NAME A TOOL MAKES A FOLDER OF (#989). `family.py + init` asked it of `--family` since #688 (`family-bad-holder-name`), and + the scaffold never asked it of `--project`: `--project NUL` and `Con`, + each one CamelCase token, which is all the naming policy asks, made the + three repositories, the working trees and, under `--local-remote-dir`, + the bare `NUL.git`, a root no Windows disk can clone. Each tool asks it + here now, so the two cannot part again. + + THE QUESTION IS THE LEG PATH RULE'S (`_unmountable_reason`), asked of a + path of one segment: a Windows device name, with or without an + extension, in any case (`CON`, `Nul`, `com1`, `NUL.git`); a name ending + in a dot or a space; one of Git's own names. Each tool asks it after its + naming policy has said what the name may hold, so in practice a device + name is the answer. A device name is the WHOLE name: `Nullable` and + `NUL-spec` are folders Windows makes. As `refuse_bad_leg_paths` does, + the caller gives its own code, the flag, what the name names (`names`, + the sentence's middle: "which names , and .") and its own + remediation, so each tool refuses in its own voice.""" + reason = _unmountable_reason(name) + if reason: + raise Refusal(refusal, + f"{flag} is {name!r}, which names {names}, and " + f"{reason}.", + remediation) + + #: The templates the rule below reads its lines from: the shape's own #: `templates/`, which both tools render a project from (`SHAPE_ROOT / #: "templates"`, and this file is `SHAPE_ROOT/scripts/`). A test points it diff --git a/tests/test_derived_id_refusals.py b/tests/test_derived_id_refusals.py index dff972b8..93bee99c 100644 --- a/tests/test_derived_id_refusals.py +++ b/tests/test_derived_id_refusals.py @@ -273,6 +273,21 @@ def test_a_name_no_holder_can_have_speaks_before_a_passed_id(tmp_path, name, assert "family-bad-id" not in stderr +@pytest.mark.parametrize("passed", ["foo_bar", ""]) +def test_a_name_no_root_can_have_on_windows_speaks_before_a_passed_id( + tmp_path, passed): + """The scaffold's half of the row above (#989): `--project NUL`, which + no Windows disk can make a folder of, is refused as + `scaffold-bad-project-name` before a passed id that is no id, as `init` + refuses `--family NUL` before its own; no `--id` mends it.""" + stderr = run_tool(tmp_path, "scaffold", "NUL", f"--id={passed}") + assert stderr.startswith( + "REFUSED scaffold-bad-project-name: --project is 'NUL', which names " + "the assembly root and the folders the scaffold makes for it, and "), \ + stderr + assert "scaffold-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 diff --git a/tests/test_scaffold_device_names.py b/tests/test_scaffold_device_names.py new file mode 100644 index 00000000..7fbae5a5 --- /dev/null +++ b/tests/test_scaffold_device_names.py @@ -0,0 +1,187 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`scaffold-project.py` refuses a `--project` no Windows disk can make a +folder of, as `family.py init` refuses such a `--family` (#989). + +WHAT WAS WRONG (RV972's N5, on #972). `--project NUL` and `--project Con` +are each one CamelCase token, which is all the naming policy asks of a +project's name, so the scaffold planned them, exit 0, and the real run made +the three repositories, the working trees `NUL`, `NUL-spec` and `NUL-code` +and, under `--local-remote-dir`, the bare `NUL.git`: an assembly root no +Windows disk can clone, into a folder of its name. `family.py init` refused +the same name as `--family` since #688, as `family-bad-holder-name`; the +scaffold never asked. + +THE CURE. One function, `shape_materialize.refuse_an_unholdable_name`, the +leg path rule's question of one segment, which both tools ask now: the +scaffold of `--project` as `scaffold-bad-project-name`, before +`_declaration` (the first place a `--pin` can reach `gh`), so before +anything exists and under `--dry-run` alike; `init` of `--family`, as +before. A device name is the WHOLE name: `Nullable`, `Console` and `Auxo` +still scaffold, and the legs' `NUL-spec` and `NUL-code` are not asked. +The naming refusal still speaks first: a device-shaped name the policy +refuses too (`NUL.git`) is refused as the policy refuses it. + +Every run but the GitHub rows is into `--local-remote-dir`, which never +calls `gh`. +""" + +from __future__ import annotations + +import pytest + +from conftest import ORG, REPO, SCAFFOLD, WINDOWS_SKIP, run_script +from test_dry_run_parity import NOT_FOUND, stub_gh +from test_scaffold_init_empty_values import DRY_RUN_DONE, everything_under + +FAMILY = REPO / "scripts" / "family.py" + +DRY_RUN = pytest.mark.parametrize("dry_run", [True, False], + ids=["dry-run", "real-run"]) + +#: The issue's rows: each scaffolded, exit 0, at `cf66102f`. +DEVICE_NAMES = ["NUL", "Con", "Nul", "com1", "Lpt9", "COM9", "Aux", "Prn"] + +#: Names that only begin like a device name, which Windows makes. +CONTROLS = ["Nullable", "Console", "Auxo"] + +#: What `scaffold-bad-project-name` is told to do, whole. +PROJECT_NAME_REMEDY = ( + "Remediation: nothing was created, and `--dry-run` refuses the same. " + "Choose another --project. It names the assembly root: its repository, " + "the folder it is cloned into, its working tree and, under " + "--local-remote-dir, its bare repository `.git`; and a root is " + "cloned on Windows too, where no file or folder can be called a device " + "name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`, in any " + "case, with or without an extension), and a name ending in a dot or a " + "space is made under another name. There is no --force.") + +#: A neutral-product pin with no `--pin-source`: `_declaration` reads its +#: digest with `gh api`, the first `gh` call a run can make. +PIN = f"openGlass@{'0' * 40}" + + +def bad_project_name(project: str) -> str: + """The scaffold's refusal of `project`, whole.""" + return (f"REFUSED scaffold-bad-project-name: --project is {project!r}, " + "which names the assembly root and the folders the scaffold " + f"makes for it, and `{project}` is a Windows device name (with " + "or without an extension), which cannot be created there.\n" + f"{PROJECT_NAME_REMEDY}\n") + + +def scaffold(tmp_path, project: str, *extra: str, dry_run: bool = True, + local: bool = True, env: dict | None = None): + """One run of the scaffold for `project` from `tmp_path/here`, building + in `tmp_path/work` and, when `local`, making its bare repositories in + `tmp_path/remotes`.""" + here = tmp_path / "here" + here.mkdir(parents=True, exist_ok=True) + return run_script( + SCAFFOLD, "--org", ORG, "--project", project, + "--elected-by", "Test Human", "--work-dir", str(tmp_path / "work"), + *(["--local-remote-dir", str(tmp_path / "remotes")] if local else []), + *extra, *(["--dry-run"] if dry_run else []), cwd=here, env=env) + + +def init(tmp_path, family: str): + """One dry run of `family.py init` for `family`, into a local remote.""" + here = tmp_path / "here" + here.mkdir(parents=True, exist_ok=True) + return run_script( + FAMILY, "init", "--org", ORG, "--family", family, + "--created-by", "Test Human", "--dry-run", + "--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(tmp_path / "work"), cwd=here) + + +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 ")), "") + + +@DRY_RUN +@pytest.mark.parametrize("project", DEVICE_NAMES) +def test_a_device_name_is_refused_as_the_project_and_nothing_is_made( + tmp_path, project, dry_run): + """THE ISSUE'S ROWS, by the dry run and the real run: exit 2 with the + new code, no plan, and nothing made, neither a working tree nor a bare + repository.""" + result = scaffold(tmp_path, project, dry_run=dry_run) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stdout == "", "the plan is never printed" + assert result.stderr == bad_project_name(project) + assert everything_under(tmp_path) == ["here"], "the run made something" + + +@WINDOWS_SKIP # the `gh` stub is a `#!` script, which Windows does not run +@DRY_RUN +def test_against_github_the_name_is_refused_before_gh_is_asked(tmp_path, + dry_run): + """Against GitHub, with a `--pin` whose digest `_declaration` would + read with `gh api`: the name is refused first, and `gh` is never + called.""" + env, calls = stub_gh(tmp_path, NOT_FOUND) + result = scaffold(tmp_path, "NUL", "--pin", PIN, dry_run=dry_run, + local=False, env=env) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr == bad_project_name("NUL") + assert calls() == [] + assert not (tmp_path / "work").exists() + + +@pytest.mark.parametrize("project", CONTROLS) +def test_a_name_that_only_begins_like_a_device_still_scaffolds(tmp_path, + project): + """THE CONTROLS: a device name is the whole name, so these plan as they + did at `cf66102f`, and the legs' names are never asked.""" + result = scaffold(tmp_path, project) + assert result.returncode == 0, result.stdout + result.stderr + assert f" assembly {ORG}/{project} " in result.stdout + assert DRY_RUN_DONE in result.stdout + assert "REFUSED" not in result.stderr + + +#: Every name both tools must refuse as a name no Windows disk can make a +#: folder of, and every name both must take: one list, so the scaffold's +#: `--project` and `init`'s `--family` cannot part again. +BOTH_TOOLS = ([(name, True) for name in DEVICE_NAMES + + ["CON", "PRN", "AUX", "nul", "COM1", "LPT1", "lpt5"]] + + [(name, False) for name in CONTROLS + + ["COM0", "LPT10", "CONX", "Conan", "Nullify"]]) + + +@pytest.mark.parametrize("name, refused", BOTH_TOOLS, + ids=[name for name, _ in BOTH_TOOLS]) +def test_both_tools_refuse_the_same_names(tmp_path, name, refused): + """The scaffold refuses a `--project` exactly where `init` refuses the + same `--family`, each in its own code, and takes the rest.""" + made = scaffold(tmp_path / "scaffold", name) + held = init(tmp_path / "family", name) + if refused: + assert made.returncode == held.returncode == 2, (made.stderr + + held.stderr) + assert refused_line(made).startswith( + f"REFUSED scaffold-bad-project-name: --project is {name!r}, ") + assert refused_line(held).startswith( + f"REFUSED family-bad-holder-name: --family is {name!r}, ") + else: + assert made.returncode == held.returncode == 0, (made.stderr + + held.stderr) + + +@pytest.mark.parametrize("project", ["NUL.git", "Nul."]) +@pytest.mark.parametrize("passed", ["foo", "foo_bar"]) +def test_a_name_the_policy_refuses_too_is_refused_by_the_policy( + tmp_path, project, passed): + """THE NAMING REFUSAL SPEAKS FIRST, as `init` asks `_classify_family_name` + before its own: a device-shaped name the policy holds as no assembly + root is refused as `naming-unclassified`, whatever the id, and is never + told it is a device name.""" + result = scaffold(tmp_path, project, f"--id={passed}") + assert result.returncode == 2, result.stdout + result.stderr + assert refused_line(result).startswith( + f"REFUSED naming-unclassified: {project!r} matches no family in the " + "naming policy."), result.stderr + assert "scaffold-bad-project-name" not in result.stderr + assert "scaffold-bad-id" not in result.stderr From dd5d94a368d0083186768203d622e559f76cc0eb Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 19 Oct 2026 21:36:48 +0000 Subject: [PATCH 2/3] scaffold-project.py, family.py and docs/cli.md: the `--help` of the five folder flags says an empty value is refused as `-no-path` and that `.` means the directory the run stands in, as `--pin-source`'s says of its own (part of #999) The scaffold's `--work-dir` and `--local-remote-dir`, and `family.py init`'s `--into`, `--work-dir` and `--local-remote-dir`, each refused an empty value by name since #972 with no word in `--help`. `docs/cli.md` is regenerated with `scripts/render-cli-reference.py`. The docstring word in `templates/assembly-root/scripts/validate-manifest.py`, #999's second part, waits for the next change to that pinned copy. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 20 ++++++++++++---- scaffold-project.py | 9 +++++-- scripts/family.py | 13 +++++++--- tests/test_scaffold_init_empty_values.py | 30 ++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 5fbb50b1..ab6258e3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -351,7 +351,9 @@ options: 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) + repo create` (the TEST path; no network). An empty + value is refused as `local-remote-dir-no-path`, and + `.` means the directory the run stands in. --no-push --reuse-empty-repo if /, -spec or -code already exists and has ZERO commits, use it instead of @@ -392,7 +394,9 @@ options: top level, or a bare clone's: any other path is refused as `pin-source-not-a-clone`. --work-dir WORK_DIR where the working trees are built (default: a - temporary directory) + temporary directory). An empty value is refused as + `work-dir-no-path`, and `.` means the directory the + run stands in. --no-next-steps leave out the closing NEXT STEPS (the clone, the cd and `make bootstrap`): setup-project.py passes it, because it runs those steps itself next. `Working @@ -923,16 +927,22 @@ options: slot. --local-remote-dir LOCAL_REMOTE_DIR create a bare repository here instead of calling `gh - repo create` (the TEST path; no network) + repo create` (the TEST path; no network). An empty + value is refused as `local-remote-dir-no-path`, and + `.` means the directory the run stands in. --into DIR 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. + clones the members. An empty value is refused as + `into-no-path`, and `.` means the directory the run + stands in. --work-dir WORK_DIR THE OVERRIDE: land the holder at / and make no family folder at all. Mutually exclusive - with --into. + with --into. An empty value is refused as `work-dir- + no-path`, and `.` means the directory the run stands + in. --no-push --dry-run print the plan and ask, as the real run does, whether each folder it makes can be made (the family folder or diff --git a/scaffold-project.py b/scaffold-project.py index c0c6b8f2..2599340b 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -380,7 +380,10 @@ def main(argv: list[str] | None = None) -> int: # 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)") + "`gh repo create` (the TEST path; no network). " + "An empty value is refused as " + "`local-remote-dir-no-path`, and `.` means the " + "directory the run stands in.") parser.add_argument("--no-push", action="store_true") parser.add_argument("--reuse-empty-repo", action="store_true", help="if /, -spec or " @@ -433,7 +436,9 @@ def main(argv: list[str] | None = None) -> int: "`pin-source-not-a-clone`.") parser.add_argument("--work-dir", default=None, help="where the working trees are built " - "(default: a temporary directory)") + "(default: a temporary directory). An empty " + "value is refused as `work-dir-no-path`, and `.` " + "means the directory the run stands in.") parser.add_argument("--no-next-steps", action="store_true", help="leave out the closing NEXT STEPS (the clone, " "the cd and `make bootstrap`): setup-project.py " diff --git a/scripts/family.py b/scripts/family.py index 879a2bfc..ee0813c4 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -10520,18 +10520,25 @@ def build_parser() -> argparse.ArgumentParser: # `_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)") + "`gh repo create` (the TEST path; no network). An " + "empty value is refused as " + "`local-remote-dir-no-path`, and `.` means the " + "directory the run stands in.") 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.") + "beside where `make siblings` clones the members. " + "An empty value is refused as `into-no-path`, and " + "`.` means the directory the run stands in.") 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.") + "all. Mutually exclusive with --into. An empty " + "value is refused as `work-dir-no-path`, and `.` " + "means the directory the run stands in.") 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, " diff --git a/tests/test_scaffold_init_empty_values.py b/tests/test_scaffold_init_empty_values.py index b07d10ee..79a83981 100644 --- a/tests/test_scaffold_init_empty_values.py +++ b/tests/test_scaffold_init_empty_values.py @@ -291,3 +291,33 @@ def test_a_folder_flag_not_given_keeps_its_default(tmp_path, tool, flag): holder = (tmp_path / "here" / "NorthWind" / "NorthWind").resolve() assert f"landing {holder}" in result.stdout, result.stdout assert everything_under(tmp_path) == ["here"] + + +def option_help(tool: str, flag: str) -> str: + """`flag`'s entry in `tool`'s `--help`, its lines joined: argparse is + given a terminal wide enough that it breaks no word, a code's hyphen + among them.""" + result = run_script(*TOOLS[tool]["command"], "--help", + env={"COLUMNS": "1000"}) + assert result.returncode == 0, result.stderr + entries, current = {}, None + for line in result.stdout.splitlines(): + if line.startswith(" -"): + current = line.split()[0] + entries[current] = [line] + elif current and line.startswith(" "): + entries[current].append(line) + else: + current = None + assert flag in entries, result.stdout + return " ".join(" ".join(entries[flag]).split()) + + +@pytest.mark.parametrize("tool, flag", FOLDER_PARAMS) +def test_each_folder_flag_s_help_says_what_empty_and_a_dot_mean(tool, flag): + """#999: each folder flag's `--help` says what #972 made true, as + `--pin-source`'s says `pin-source-no-path`: an empty value is refused by + its code, and `.` is the directory the run stands in.""" + code = FOLDER_ROWS[tool, flag][0] + assert (f"An empty value is refused as `{code}`, and `.` means the " + "directory the run stands in.") in option_help(tool, flag) From 37b0fc438d2516e921a4c5dbbfb6dc8bf629b252 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 19 Oct 2026 21:36:48 +0000 Subject: [PATCH 3/3] test_github_limits_init_check_org.py: a row holds that `init` asks no GitHub length of a `--family` under `--local-remote-dir` before a passed id that is no id, so it says `family-bad-id`, never `family-name-too-long` (#972's review, lane 4's SHOULD-1) Dropping `if args.local_remote_dir is not None: return` from `_refuse_a_holder_name_github_cannot_hold` survived every test before this row; it fails this one. Co-Authored-By: Claude Opus 5.5 --- tests/test_github_limits_init_check_org.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_github_limits_init_check_org.py b/tests/test_github_limits_init_check_org.py index f7a9076b..8fd811f8 100644 --- a/tests/test_github_limits_init_check_org.py +++ b/tests/test_github_limits_init_check_org.py @@ -314,6 +314,22 @@ def test_init_refuses_a_long_family_before_a_passed_bad_id( assert "family-bad-id" not in err +def test_init_asks_no_length_of_a_local_run_before_a_passed_bad_id( + family, only_the_branch_question, capsys, tmp_path): + """Lane 4's SHOULD-1 on #972: under `--local-remote-dir` a bare + repository's name is the disk's, so a `--family` over GitHub's 100 with + a passed id that is no id is told the id, never `family-name-too-long`. + Only this branch reaches `_refuse_a_holder_name_github_cannot_hold`'s + own skip: `_refuse_what_github_cannot_hold` returns before it asks.""" + err = refused_init(family, capsys, tmp_path, "--family", + a_name(NAME_MAX + 1), "--id", "foo_bar", + "--local-remote-dir", str(tmp_path / "remotes")) + assert err.startswith("REFUSED family-bad-id: --id 'foo_bar' must match" + ), err + assert "family-name-too-long" not in err + assert not (tmp_path / "remotes").exists() + + def test_init_names_a_passed_id_as_passed(family, only_the_branch_question, capsys, tmp_path): err = refused_init(family, capsys, tmp_path, "--id", an_id(ID_MAX + 1))