diff --git a/docs/cli.md b/docs/cli.md index 7c53f421..cb7784f4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -305,7 +305,11 @@ options: or lies inside a FILE the shape writes into the root is refused before anything is created: `project.yaml` is one, and `contracts` holds several. - --dry-run + --dry-run print the plan and ask, as the real run does, whether + each of the three repositories already exists (`gh + repo view` and its commit listing, or the bare + repositories with --local-remote-dir), refusing what + it refuses; create no remote and push nothing --local-remote-dir LOCAL_REMOTE_DIR create bare repositories here instead of calling `gh repo create` (the TEST path; no network) @@ -734,7 +738,11 @@ options: and make no family folder at all. Mutually exclusive with --into. --no-push - --dry-run + --dry-run print the plan and ask, as the real run does, whether + / already exists (`gh repo view` and its + commit listing; with --local-remote-dir, the bare + repository), refusing what it refuses; make no folder, + remote or repository ``` #### `scripts/family.py add` diff --git a/scaffold-project.py b/scaffold-project.py index 5556f18b..5f25838c 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -57,7 +57,9 @@ while it has ZERO commits, and a leg whose one commit is the seed this scaffold pushes (#462): a reserved name, or what a run whose later push an organisation's ruleset refused left behind. It never deletes, rewrites or -force-pushes a remote. +force-pushes a remote. `--dry-run` asks the same question of the three +remotes and refuses `scaffold-remote-exists` as the real run does (#537), so +`setup-project.py` refuses at the plan, before it asks for the yes. EXIT CODES: 0 done · 1 nothing (dry run prints and exits 0) · 2 refusal. """ @@ -136,6 +138,21 @@ CHAIN_LINK_FORM = "neutral-product" +def _gh_not_found(question: str, exc: FileNotFoundError) -> Refusal: + """NO `gh` ON PATH IS `gh-not-found` (#537), as `pin_digest_from_gh` names + it, and not a `FileNotFoundError` traceback. Each `gh` read this file makes + before it creates anything refuses with this one, in these words, where + `question` is what could not be asked.""" + return Refusal( + "gh-not-found", + f"the `gh` CLI is not on PATH, so {question} cannot be asked: {exc}", + "Remediation: put `gh` on PATH, logged in (`gh auth login`), and " + "run this again: the real run creates the three repositories " + "with `gh repo create`. `openRepoShape --preflight` names each " + "missing prerequisite and offers to install it, on a typed " + "yes.") + + def remote_is_empty(repository: str) -> bool | None: """Does `/` exist on GitHub with ZERO commits? @@ -143,10 +160,19 @@ def remote_is_empty(repository: str) -> bool | None: does not exist. GitHub answers a commit listing for an empty repository with 409 and the words "Git Repository is empty", which is the only reliable signal — `size: 0` is also what a repository of small files says. + + NO `gh` ON PATH IS `gh-not-found` (#537), and not a `FileNotFoundError` + traceback: `--dry-run` asks this question too now, and before that a dry + run needed no `gh` at all. """ - probe = subprocess.run(["gh", "repo", "view", repository, "--json", "name"], - capture_output=True, text=True, - encoding="utf-8", errors="replace", check=False) + try: + probe = subprocess.run( + ["gh", "repo", "view", repository, "--json", "name"], + capture_output=True, text=True, encoding="utf-8", + errors="replace", check=False) + except FileNotFoundError as exc: + raise _gh_not_found( + f"whether {repository} already exists on GitHub", exc) from exc if probe.returncode != 0: return None commits = subprocess.run( @@ -174,11 +200,21 @@ def remote_visibility(repository: str) -> str | None: lowercased, or None if it cannot be read — the repository does not exist, or `gh` cannot see it. Never called for a `--local-remote-dir` repository: a bare repository on disk has no visibility at all. + + NO `gh` ON PATH IS `gh-not-found` (#537), as `remote_is_empty`'s is, and + not a `FileNotFoundError` traceback: `--reuse-empty-repo` reads the root's + visibility (`_leg_visibility`) BEFORE it asks `remote_is_empty` whether the + three repositories exist, in a dry run and a real one alike, so the refusal + has to come from here too or the run never reaches the one that has it. """ - proc = subprocess.run( - ["gh", "repo", "view", repository, "--json", "visibility"], - capture_output=True, text=True, encoding="utf-8", errors="replace", - check=False) + try: + proc = subprocess.run( + ["gh", "repo", "view", repository, "--json", "visibility"], + capture_output=True, text=True, encoding="utf-8", + errors="replace", check=False) + except FileNotFoundError as exc: + raise _gh_not_found( + f"what visibility {repository} has on GitHub", exc) from exc if proc.returncode != 0: return None try: @@ -262,7 +298,13 @@ def main(argv: list[str] | None = None) -> int: "writes into the root is refused before anything " "is created: `project.yaml` is one, and " "`contracts` holds several.") - parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--dry-run", action="store_true", + help="print the plan and ask, as the real run does, " + "whether each of the three repositories already " + "exists (`gh repo view` and its commit listing, " + "or the bare repositories with " + "--local-remote-dir), refusing what it refuses; " + "create no remote and push nothing") parser.add_argument("--local-remote-dir", type=Path, default=None, help="create bare repositories here instead of calling " "`gh repo create` (the TEST path; no network)") @@ -1593,6 +1635,16 @@ def _scaffold(args) -> int: _print_plan(args, values, names, repositories, urls, local, policy, declared_pins, chain, link_pins, pins) if args.dry_run: + # THE DRY RUN ASKS WHETHER THE REMOTES EXIST (#537), with the real + # run's own question, `_reusable_remotes`, and refuses what it + # refuses: `scaffold-remote-exists` used to come only from the real + # run, which `setup-project.py` starts after the yes. It READS: `gh + # repo view` and a commit listing, or the bare repositories on disk + # with `--local-remote-dir`, and for a leg that is not empty the one + # commit it holds, fetched into a temporary directory it deletes. It + # creates no remote and pushes nothing. + _reusable_remotes(names, urls, repositories, local, + args.reuse_empty_repo, values, args.tracking_branch) print("\n--dry-run: nothing was created.") return 0 diff --git a/scripts/family.py b/scripts/family.py index 674e41d5..7c3b57b5 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -86,6 +86,11 @@ and prints that row, the gitlink, the `.gitmodules` section, the registration and the directory the mount would make, and the trailers its commit would end with; it never runs `git submodule add` and writes nothing of the holder's. +`init --dry-run` asks whether `/` already exists, with the +question the real run asks before it creates the holder (#537): `gh repo +view` and the commit listing, or the bare repository with +`--local-remote-dir`. It refuses `family-remote-exists` as the real run does, +and makes no folder, remote or repository. WHAT ONLY THE WRITE ITSELF CAN ANSWER IS NOT ASKED. A member whose local changes `git checkout` would refuse to overwrite gets exit 0 and a row from @@ -842,10 +847,25 @@ def _remote_is_empty(repository: str) -> bool | None: does not exist. GitHub answers a commit listing for an empty repository with 409 and the words "Git Repository is empty", which is the only reliable signal — `size: 0` is also what a repository of small files says. + + NO `gh` ON PATH IS `gh-not-found` (#537), as `pin_digest_from_gh` names + it, and not a `FileNotFoundError` traceback: `init --dry-run` asks this + question too now, and before that a dry run needed no `gh` at all. """ - probe = subprocess.run(["gh", "repo", "view", repository, "--json", "name"], - capture_output=True, text=True, - encoding="utf-8", errors="replace", check=False) + try: + probe = subprocess.run( + ["gh", "repo", "view", repository, "--json", "name"], + capture_output=True, text=True, encoding="utf-8", + errors="replace", check=False) + except FileNotFoundError as exc: + raise Refusal( + "gh-not-found", + f"the `gh` CLI is not on PATH, so whether {repository} already " + f"exists on GitHub cannot be asked: {exc}", + "Remediation: put `gh` on PATH, logged in (`gh auth login`), and " + "run this again: the real run creates the holder with `gh repo " + "create`. `openRepoShape --preflight` names each missing " + "prerequisite and offers to install it, on a typed yes.") from exc if probe.returncode != 0: return None commits = subprocess.run( @@ -1736,6 +1756,13 @@ def cmd_init(args) -> int: _print_init_plan(values, repository, url, shape_commit, shape_tree, folder, holder, local, topic, args.reuse_empty_repo) if args.dry_run: + # THE DRY RUN ASKS WHETHER THE HOLDER'S REMOTE EXISTS (#537), with + # the real run's own question, `_checked_reuse`, and refuses what it + # refuses: `family-remote-exists` used to come only from the real + # run, after `_prepare_landing_dir` had made the family folder. It + # READS, a `gh repo view` and a commit listing, or the bare + # repository on disk with `--local-remote-dir`, and creates nothing. + _checked_reuse(args.reuse_empty_repo, local, url, repository) print("\n--dry-run: nothing was created.") return 0 @@ -4956,7 +4983,13 @@ def build_parser() -> argparse.ArgumentParser: "/ and make no family folder at " "all. Mutually exclusive with --into.") init.add_argument("--no-push", action="store_true") - init.add_argument("--dry-run", action="store_true") + init.add_argument("--dry-run", action="store_true", + help="print the plan and ask, as the real run does, " + "whether / already exists (`gh repo " + "view` and its commit listing; with " + "--local-remote-dir, the bare repository), " + "refusing what it refuses; make no folder, " + "remote or repository") init.set_defaults(func=cmd_init) add = subparsers.add_parser( diff --git a/tests/conftest.py b/tests/conftest.py index 6c8800b6..dff0a90f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -446,6 +446,30 @@ def run_script(script: Path, *args: str, cwd: Path | None = None, stdin=stdin) +def scaffold_dry_run_on_github(monkeypatch, capsys, *args: str): + """`scaffold-project.py --dry-run` with no `--local-remote-dir`, + IN PROCESS, and `(exit code, stdout, stderr, the repositories asked + about)`. + + THE DRY RUN ASKS GITHUB NOW (#537): `remote_is_empty`, a `gh repo view` + of each of the three, as the real run does. A stub `gh` is a `#!` script + Windows does not run, so a test of the plan a real organisation is shown + replaces that one question with a stand-in that answers "none of the + three exists" and records whom it was asked about. No `gh` is started on + any platform, and every other step of the dry run is the tool's own. + """ + spec = importlib.util.spec_from_file_location("scaffold_on_github", + SCAFFOLD) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + asked: list = [] + monkeypatch.setattr(module, "remote_is_empty", + lambda repository: asked.append(repository)) + code = module.main([*args, "--dry-run"]) + out, err = capsys.readouterr() + return code, out, err, asked + + @pytest.fixture(scope="session") def scaffolded(tmp_path_factory) -> dict: """One real scaffold into local bare remotes, plus a recursive clone.""" @@ -1024,6 +1048,43 @@ def assert_a_taken_clone_path_is_refused(run, tmp_path: Path, assert after == before, f"{taken} was changed by a run that refused" +def assert_the_plan_refuses_a_remote_that_exists(run, tmp_path: Path) -> None: + """A leg that already exists with commits is refused in step (5), the + plan, which is the scaffold's own `--dry-run` (#537): the scaffold's + `scaffold-remote-exists`, before the yes is asked, and nothing created. + On main the plan passed it, and a run with no terminal stopped at the + question instead; with one, the refusal came from step (6), after the + yes.""" + remotes = tmp_path / "remotes" + seed = tmp_path / "somebody" + seed.mkdir() + git("init", "-q", "-b", "main", str(seed), cwd=seed) + (seed / "README.md").write_bytes(b"# somebody's\n") + git("add", "README.md", cwd=seed) + git("commit", "-q", "-m", "somebody's", cwd=seed) + leg = remotes / "Sample-spec.git" + remotes.mkdir() + git("init", "-q", "--bare", "-b", "main", str(leg), cwd=remotes) + git("push", "-q", str(leg), "main", cwd=seed) + before = git("for-each-ref", cwd=leg).stdout + result = run("--project", "Sample", "--org", "demoorg", + "--local-remote-dir", str(remotes), + "--into", str(tmp_path / "work")) + assert result.returncode == 2, result.stderr + result.stdout + assert (f"REFUSED scaffold-remote-exists: {leg.resolve()} already exists " + "and has commits" in result.stderr), result.stderr + assert "REFUSED: the plan could not be produced" in result.stderr + assert "(5) the plan" in result.stdout, result.stdout + assert "--dry-run: nothing was created." not in result.stdout + assert "(6) scaffold" not in result.stdout, "the run reached (6)" + assert "This will create THREE repositories" not in result.stdout + assert "no terminal to confirm on" not in result.stderr, ( + "the run reached the question") + assert sorted(p.name for p in remotes.iterdir()) == ["Sample-spec.git"] + assert git("for-each-ref", cwd=leg).stdout == before + assert not (tmp_path / "work").exists() + + #: THE PERSON'S OWN TERMINAL, IN A TEST (#492). The one-liner reads its yes #: from the CONTROLLING terminal when its stdin is the pipe the script came #: down, so a test that says it asks has to give the run one: a diff --git a/tests/test_dry_run_asks_remotes.py b/tests/test_dry_run_asks_remotes.py new file mode 100644 index 00000000..31c87771 --- /dev/null +++ b/tests/test_dry_run_asks_remotes.py @@ -0,0 +1,484 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`family.py init --dry-run` and `scaffold-project.py --dry-run` ask whether +the remotes already exist (#537). + +WHAT WAS WRONG. Both dry runs printed the plan and exited 0 before the one +question their real runs ask of the remotes: `_checked_reuse` for the +family's holder and `_reusable_remotes` for the scaffold's three +repositories. Against a remote that already exists, the dry run said nothing +and the real run then refused: `family.py init` with `family-remote-exists` +after it had made the family folder, and the scaffold with +`scaffold-remote-exists` from step (6) of `setup-project.py`, after the yes. + +THE CURE. Each dry run asks the real run's own question, never a second one +that could disagree with it, after the plan and before it says nothing was +created, and refuses what the real run refuses. It reads and writes nothing +that outlives it: `gh repo view` and a commit listing, or the bare +repositories on disk with `--local-remote-dir`, and for a scaffold leg that +is not empty the one commit it holds, fetched into a temporary directory the +real run's question deletes. + +Most tests here run against bare repositories on disk. The GitHub route runs +against a stub `gh` on PATH that answers the two reads and refuses anything +else, and is POSIX only, as every `#!` stub in this suite is; where no `gh` +can be started at all, the refusal is asked in process, on every platform. +The step (5) test of `setup-project.py` and `setup.sh` is +`conftest.assert_the_plan_refuses_a_remote_that_exists`. +""" + +from __future__ import annotations + +import importlib.util +import os +import stat +import subprocess +from pathlib import Path + +import pytest + +from conftest import ORG, REPO, SCAFFOLD, WINDOWS_SKIP, git, run_script +from test_scaffold_reuse_seeded_legs import (REPOS, bare, commits, + refused_at_the_root, tip) +from test_ruleset_hint_tracking_branch import template # noqa: F401 + +FAMILY = REPO / "scripts" / "family.py" +HOLDER = "Contoso" +PROJECT = "Atlas" +DRY_RUN_DONE = "--dry-run: nothing was created." + + +def scratch_env(tmp_path: Path) -> dict: + """`TMPDIR`, `TMP` and `TEMP` at an empty `tmp_path/tmp`, so a test sees + whether a run left anything in the temporary directory.""" + scratch = tmp_path / "tmp" + scratch.mkdir(exist_ok=True) + return {"TMPDIR": str(scratch), "TMP": str(scratch), "TEMP": str(scratch)} + + +def empty_bare(path: Path) -> Path: + """A bare repository at `remotes/.git` with zero commits: a + reserved name.""" + path.parent.mkdir(parents=True, exist_ok=True) + git("init", "-q", "--bare", "-b", "main", str(path), cwd=path.parent) + return path + + +def live_bare(path: Path) -> Path: + """A bare repository at `remotes/.git` holding one commit of + somebody else's, made in a clone beside `remotes/`.""" + work = path.parent.parent / "somebody" / path.stem + work.mkdir(parents=True) + git("init", "-q", "-b", "main", str(work), cwd=work) + (work / "README.md").write_bytes(b"# somebody's\n") + git("add", "README.md", cwd=work) + git("commit", "-q", "-m", "somebody's", cwd=work) + git("push", "-q", str(empty_bare(path)), "main", cwd=work) + return path + + +def refs(path: Path) -> str: + return git("for-each-ref", cwd=path).stdout + + +def load(name: str, path: Path): + """A tool as a module, for the in-process tests.""" + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def assert_refused_after_the_plan(result, refusal: str, plan_line: str) -> None: + """Exit 2 with `refusal`, after the plan was printed, and without the + line that says the dry run is done.""" + assert result.returncode == 2, result.stderr + result.stdout + assert refusal in result.stderr, result.stderr + assert "Traceback" not in result.stderr, result.stderr + assert plan_line in result.stdout, result.stdout + assert DRY_RUN_DONE not in result.stdout, result.stdout + + +# --- family.py init --dry-run -------------------------------------------------- + +def family_init(tmp_path: Path, *extra: str, into: str = "into", + dry_run: bool = True, env: dict | None = None, + local: bool = True): + return run_script( + FAMILY, "init", "--org", ORG, "--family", HOLDER, + "--created-by", "Test Human", "--into", str(tmp_path / into), + *(["--local-remote-dir", str(tmp_path / "remotes")] if local else []), + *extra, *(["--dry-run"] if dry_run else []), + env={**scratch_env(tmp_path), **(env or {})}) + + +def holder_remote(tmp_path: Path) -> Path: + return tmp_path / "remotes" / f"{HOLDER}.git" + + +@pytest.mark.parametrize("flag", [[], ["--reuse-empty-repo"]], + ids=["no-flag", "reuse-empty-repo"]) +def test_init_dry_run_refuses_a_holder_remote_with_commits(tmp_path, flag): + """The issue's line: on main this exited 0, and the real run then refused + `family-remote-exists`, leaving an empty `//` behind.""" + remote = live_bare(holder_remote(tmp_path)) + before = refs(remote) + result = family_init(tmp_path, *flag) + assert_refused_after_the_plan( + result, f"REFUSED family-remote-exists: {remote.resolve()} already " + "exists and has commits", " holder ") + assert "it HAS commits, so it is a live repository" in result.stderr + assert not (tmp_path / "into").exists(), "the dry run made the folder" + assert refs(remote) == before + assert list((tmp_path / "tmp").iterdir()) == [] + + +def test_init_dry_run_names_the_flag_for_an_empty_holder_remote(tmp_path): + remote = empty_bare(holder_remote(tmp_path)) + result = family_init(tmp_path) + assert_refused_after_the_plan( + result, f"REFUSED family-remote-exists: {remote.resolve()} already " + "exists\n", " holder ") + assert "re-run with --reuse-empty-repo" in result.stderr + assert not (tmp_path / "into").exists() + + +def test_init_dry_run_reuses_an_empty_holder_remote_when_asked(tmp_path): + """The flag's answer, said by the dry run as the real run says it, and + the reserved repository left with zero commits.""" + remote = empty_bare(holder_remote(tmp_path)) + result = family_init(tmp_path, "--reuse-empty-repo") + assert result.returncode == 0, result.stderr + result.stdout + reuse = f" reuse {remote.resolve()} (zero commits)\n" + assert reuse in result.stdout, result.stdout + assert result.stdout.index(reuse) < result.stdout.index(DRY_RUN_DONE) + assert git("rev-list", "--all", cwd=remote).stdout == "" + assert not (tmp_path / "into").exists() + + +@pytest.mark.parametrize("state", ["live", "empty"]) +def test_init_dry_run_refuses_in_the_real_runs_own_words(tmp_path, state): + """ONE QUESTION: the dry run's refusal is the real run's, byte for byte, + because it is the same call.""" + (live_bare if state == "live" else empty_bare)(holder_remote(tmp_path)) + dry = family_init(tmp_path, into="dry") + real = family_init(tmp_path, into="real", dry_run=False) + assert dry.returncode == real.returncode == 2, real.stderr + real.stdout + assert dry.stderr == real.stderr + assert "REFUSED family-remote-exists: " in dry.stderr + + +def test_init_dry_run_still_plans_where_no_remote_exists(tmp_path): + result = family_init(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + assert DRY_RUN_DONE in result.stdout + assert " reuse " not in result.stdout + assert not (tmp_path / "remotes").exists() + assert not (tmp_path / "into").exists() + + +# --- scaffold-project.py --dry-run -------------------------------------------- + +def scaffold(tmp_path: Path, *extra: str, work: str = "work", + dry_run: bool = True, env: dict | None = None, + local: bool = True): + return run_script( + SCAFFOLD, "--org", ORG, "--project", PROJECT, + "--elected-by", "Test Human", "--elected-on", "2026-09-02", + "--work-dir", str(tmp_path / work), + *(["--local-remote-dir", str(tmp_path / "remotes")] if local else []), + *extra, *(["--dry-run"] if dry_run else []), + env={**scratch_env(tmp_path), **(env or {})}) + + +def remote(tmp_path: Path, role: str) -> Path: + return tmp_path / "remotes" / REPOS[role] + + +def remotes_there(tmp_path: Path) -> list: + return sorted(p.name for p in (tmp_path / "remotes").iterdir() + if p.name.endswith(".git")) + + +@pytest.mark.parametrize("flag", [[], ["--reuse-empty-repo"]], + ids=["no-flag", "reuse-empty-repo"]) +def test_scaffold_dry_run_refuses_a_leg_with_commits(tmp_path, flag): + """The issue's line: on main the dry run exited 0 here, so + `setup-project.py` asked for the yes and only step (6) refused.""" + leg = live_bare(remote(tmp_path, "spec")) + before = refs(leg) + result = scaffold(tmp_path, *flag) + assert_refused_after_the_plan( + result, f"REFUSED scaffold-remote-exists: {leg.resolve()} already " + "exists and has commits", "legs mounted at spec/ and code/") + assert "commits other than the one seed this scaffold would push" in ( + result.stderr) + assert remotes_there(tmp_path) == [REPOS["spec"]] + assert refs(leg) == before + assert not (tmp_path / "work").exists() + assert list((tmp_path / "tmp").iterdir()) == [], ( + "the question's temporary directory was left behind") + + +def test_scaffold_dry_run_sends_a_root_with_commits_to_adopt(tmp_path): + root = live_bare(remote(tmp_path, "assembly")) + result = scaffold(tmp_path, "--reuse-empty-repo") + assert_refused_after_the_plan( + result, f"REFUSED scaffold-remote-exists: {root.resolve()} already " + "exists and has commits", "legs mounted at spec/ and code/") + assert "adopt-project.py plan" in result.stderr + assert remotes_there(tmp_path) == [REPOS["assembly"]] + + +def test_scaffold_dry_run_names_the_flag_for_an_empty_root(tmp_path): + root = empty_bare(remote(tmp_path, "assembly")) + result = scaffold(tmp_path) + assert_refused_after_the_plan( + result, f"REFUSED scaffold-remote-exists: {root.resolve()} already " + "exists\n", "legs mounted at spec/ and code/") + assert "re-run with --reuse-empty-repo" in result.stderr + assert remotes_there(tmp_path) == [REPOS["assembly"]] + + +def test_scaffold_dry_run_reuses_empty_repositories_when_asked(tmp_path): + for role in REPOS: + empty_bare(remote(tmp_path, role)) + result = scaffold(tmp_path, "--reuse-empty-repo") + assert result.returncode == 0, result.stderr + result.stdout + for role in REPOS: + reuse = f" reuse {remote(tmp_path, role).resolve()} (zero commits)\n" + assert reuse in result.stdout, result.stdout + assert result.stdout.index(reuse) < result.stdout.index(DRY_RUN_DONE) + assert git("rev-list", "--all", cwd=remote(tmp_path, role)).stdout == "" + assert not (tmp_path / "work").exists() + + +def test_scaffold_dry_run_reuses_its_own_seed_and_changes_nothing( + tmp_path, template): # noqa: F811 + """A leg whose one commit is this scaffold's seed (#462) is asked as the + real run asks it, by fetching that commit into a temporary directory: + the dry run says it would reuse it, and leaves every remote, and the + temporary directory, as it found them.""" + seeds = refused_at_the_root(tmp_path, template) + before = {role: refs(bare(tmp_path, role)) for role in REPOS} + result = scaffold(tmp_path, "--reuse-empty-repo", work="w2") + assert result.returncode == 0, result.stderr + result.stdout + for leg, seed in seeds.items(): + assert (f" reuse {bare(tmp_path, leg).resolve()} (one commit, " + f"{seed[:12]}: the seed this scaffold pushes)") in result.stdout + assert DRY_RUN_DONE in result.stdout + assert {role: refs(bare(tmp_path, role)) for role in REPOS} == before + assert commits(tmp_path, "assembly") == 0 and tip(tmp_path, "spec") + assert not (tmp_path / "w2").exists() + assert list((tmp_path / "tmp").iterdir()) == [] + + +def test_scaffold_dry_run_still_plans_where_no_remote_exists(tmp_path): + result = scaffold(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + assert DRY_RUN_DONE in result.stdout + assert " reuse " not in result.stdout + assert not (tmp_path / "remotes").exists() + assert not (tmp_path / "work").exists() + assert list((tmp_path / "tmp").iterdir()) == [] + + +@pytest.mark.parametrize("state", ["live leg", "empty root"]) +def test_scaffold_dry_run_refuses_in_the_real_runs_own_words(tmp_path, state): + if state == "live leg": + live_bare(remote(tmp_path, "code")) + else: + empty_bare(remote(tmp_path, "assembly")) + dry = scaffold(tmp_path, work="dry") + real = scaffold(tmp_path, work="real", dry_run=False) + assert dry.returncode == real.returncode == 2, real.stderr + real.stdout + assert dry.stderr == real.stderr + assert "REFUSED scaffold-remote-exists: " in dry.stderr + + +# --- against GitHub: a stub `gh` that answers two reads and nothing else ------- + +#: `repo view --json name` and `api repos//commits?per_page=1`, +#: the two reads `remote_is_empty` and `_remote_is_empty` make, answered for +#: the one repository in `$STUB_GH_LIVE` (with commits) or `$STUB_GH_EMPTY` +#: (none); `repo view` of any other is GitHub's not-found. Anything else is +#: refused, so a dry run that created or edited a repository fails here. +STUB_GH = """#!/bin/sh +printf '%s\\n' "$*" >> "$STUB_GH_LOG" +case "$*" in + "repo view $STUB_GH_LIVE --json name"|"repo view $STUB_GH_EMPTY --json name") + echo '{"name":"x"}'; exit 0 ;; + "repo view "*) + echo "GraphQL: Could not resolve to a Repository (NOT_FOUND)" >&2 + exit 1 ;; + "api repos/$STUB_GH_LIVE/commits?per_page=1") + echo '[{"sha":"0123456789abcdef0123456789abcdef01234567"}]'; exit 0 ;; + "api repos/$STUB_GH_EMPTY/commits?per_page=1") + echo 'gh: Git Repository is empty. (HTTP 409)' >&2; exit 1 ;; +esac +echo "stub gh: not one of the two reads: $*" >&2 +exit 97 +""" + + +def stub_gh(tmp_path: Path, live: str = "-", empty: str = "-"): + """`(env, calls)`: the stub first on PATH, and a reader of its log.""" + directory = tmp_path / "ghbin" + directory.mkdir() + shim = directory / "gh" + shim.write_text(STUB_GH, encoding="utf-8") + shim.chmod(shim.stat().st_mode | stat.S_IXUSR) + log = tmp_path / "gh.log" + env = {"PATH": f"{directory}{os.pathsep}{os.environ['PATH']}", + "STUB_GH_LOG": str(log), "STUB_GH_LIVE": live, + "STUB_GH_EMPTY": empty, "GH_TOKEN": "bogus-test", + "GH_CONFIG_DIR": str(tmp_path / "ghconfig")} + + def calls() -> list: + return (log.read_text(encoding="utf-8").splitlines() + if log.exists() else []) + return env, calls + + +def viewed(repository: str) -> str: + return f"repo view {repository} --json name" + + +def listed(repository: str) -> str: + return f"api repos/{repository}/commits?per_page=1" + + +@WINDOWS_SKIP # the `gh` stub is a `#!` script, which Windows does not run +def test_init_dry_run_asks_github_and_refuses_a_live_holder(tmp_path): + repository = f"{ORG}/{HOLDER}" + env, calls = stub_gh(tmp_path, live=repository) + result = family_init(tmp_path, env=env, local=False) + assert_refused_after_the_plan( + result, f"REFUSED family-remote-exists: {repository} already exists " + "on GitHub and has commits", " holder ") + assert calls() == [viewed(repository), listed(repository)] + assert not (tmp_path / "into").exists() + + +@WINDOWS_SKIP # the `gh` stub is a `#!` script, which Windows does not run +def test_init_dry_run_against_github_only_reads(tmp_path): + env, calls = stub_gh(tmp_path) + result = family_init(tmp_path, env=env, local=False) + assert result.returncode == 0, result.stderr + result.stdout + assert DRY_RUN_DONE in result.stdout + assert calls() == [viewed(f"{ORG}/{HOLDER}")] + + +@WINDOWS_SKIP # the `gh` stub is a `#!` script, which Windows does not run +def test_scaffold_dry_run_asks_github_and_names_the_flag(tmp_path): + repository = f"{ORG}/{PROJECT}" + env, calls = stub_gh(tmp_path, empty=repository) + result = scaffold(tmp_path, env=env, local=False) + assert_refused_after_the_plan( + result, f"REFUSED scaffold-remote-exists: {repository} already exists " + "on GitHub\n", "legs mounted at spec/ and code/") + assert "re-run with --reuse-empty-repo" in result.stderr + assert calls() == [viewed(repository), listed(repository)] + + +@WINDOWS_SKIP # the `gh` stub is a `#!` script, which Windows does not run +def test_scaffold_dry_run_against_github_only_reads(tmp_path): + env, calls = stub_gh(tmp_path) + result = scaffold(tmp_path, env=env, local=False) + assert result.returncode == 0, result.stderr + result.stdout + assert DRY_RUN_DONE in result.stdout + assert calls() == [viewed(f"{ORG}/{name}") for name in + (PROJECT, f"{PROJECT}-spec", f"{PROJECT}-code")] + assert list((tmp_path / "tmp").iterdir()) == [] + + +# --- no `gh` at all: asked in process, on every platform ----------------------- + +def without_gh(monkeypatch) -> list: + """`subprocess.run` that cannot start `gh`, as on a machine with none on + PATH, and runs everything else; returns the `gh` argvs it was asked.""" + asked: list = [] + real = subprocess.run + + def run(argv, *args, **kwargs): + if list(argv)[:1] == ["gh"]: + asked.append(list(argv)) + raise FileNotFoundError(2, "No such file or directory", "gh") + return real(argv, *args, **kwargs) + monkeypatch.setattr(subprocess, "run", run) + return asked + + +def no_gh_run(tmp_path: Path, tool: str, reuse_empty_repo: bool): + """A tool run with no `gh` on PATH, as `(module, argv, refusal, first)`: + the tool as a module, its arguments (the caller adds `--dry-run` or not), + the sentence its refusal opens with, and the one `gh` argv it asks before + it is refused. + + The scaffold with `--reuse-empty-repo` reads the root's VISIBILITY before + it asks whether the repositories exist (`_leg_visibility`, then + `_reusable_remotes`), so it is refused at that read, in other words; every + other run is refused at `gh repo view --json name`.""" + if tool == "family": + module = load("dry_run_family", FAMILY) + argv = ["init", "--org", ORG, "--family", HOLDER, "--created-by", + "Test Human", "--work-dir", str(tmp_path / "work")] + repository = f"{ORG}/{HOLDER}" + else: + module = load("dry_run_scaffold", SCAFFOLD) + argv = ["--org", ORG, "--project", PROJECT, "--elected-by", + "Test Human", "--elected-on", "2026-09-02", + "--work-dir", str(tmp_path / "work")] + repository = f"{ORG}/{PROJECT}" + if reuse_empty_repo: + argv.append("--reuse-empty-repo") + reads_visibility = tool == "scaffold" and reuse_empty_repo + question = (f"what visibility {repository} has on GitHub" + if reads_visibility + else f"whether {repository} already exists on GitHub") + refusal = (f"REFUSED gh-not-found: the `gh` CLI is not on PATH, so " + f"{question} cannot be asked: ") + first = ["gh", "repo", "view", repository, "--json", + "visibility" if reads_visibility else "name"] + return module, argv, refusal, first + + +REUSE_EMPTY_REPO = pytest.mark.parametrize( + "reuse_empty_repo", [False, True], ids=["fresh", "reuse-empty-repo"]) + + +@REUSE_EMPTY_REPO +@pytest.mark.parametrize("tool", ["family", "scaffold"]) +def test_a_dry_run_with_no_gh_is_refused_by_name(tmp_path, monkeypatch, + capsys, tool, + reuse_empty_repo): + """The dry run asks `gh` now, and a machine with none on PATH gets + `gh-not-found`, as a `--pin` read names it, and not a traceback; with and + without `--reuse-empty-repo`, which the scaffold answers from a different + `gh` read than it does without it.""" + module, argv, refusal, first = no_gh_run(tmp_path, tool, reuse_empty_repo) + asked = without_gh(monkeypatch) + assert module.main([*argv, "--dry-run"]) == 2 + captured = capsys.readouterr() + assert refusal in captured.err, captured.err + assert "put `gh` on PATH, logged in" in captured.err + assert DRY_RUN_DONE not in captured.out + assert asked == [first] + assert not (tmp_path / "work").exists() + + +@REUSE_EMPTY_REPO +@pytest.mark.parametrize("tool", ["family", "scaffold"]) +def test_a_real_run_with_no_gh_is_refused_in_the_dry_runs_words( + tmp_path, monkeypatch, capsys, tool, reuse_empty_repo): + """The real run asks the same question first and gets the same refusal: + `gh-not-found`, in the dry run's own words, after the one `gh` read it + shares with the dry run and before any `gh repo create`; not a + `FileNotFoundError` traceback, which `--reuse-empty-repo` gave the + scaffold before the visibility read refused by name.""" + module, argv, refusal, first = no_gh_run(tmp_path, tool, reuse_empty_repo) + asked = without_gh(monkeypatch) + assert module.main(argv) == 2 + captured = capsys.readouterr() + assert refusal in captured.err, captured.err + assert "put `gh` on PATH, logged in" in captured.err + assert asked == [first] diff --git a/tests/test_family.py b/tests/test_family.py index d82f4f79..9a4f3efe 100644 --- a/tests/test_family.py +++ b/tests/test_family.py @@ -404,15 +404,24 @@ def test_init_plans_the_topic_and_skips_it_against_local_remotes(family): assert "gh repo edit" not in family["init"].stdout -def test_init_dry_run_plans_the_gh_topic_command_for_a_real_org(tmp_path): +def test_init_dry_run_plans_the_gh_topic_command_for_a_real_org( + tmp_path, monkeypatch, capsys): """No `--local-remote-dir`, so the plan is the REAL one — and a dry run - prints it before anything is created, so this needs no network either.""" - result = run_script(FAMILY, "init", "--org", ORG, "--family", "Contoso", + prints it before anything is created. It also asks whether the holder + exists (#537), which against GitHub is `gh repo view`: asked in process + here, of a stand-in that says it does not, so no `gh` is run.""" + module = family_module() + asked = [] + monkeypatch.setattr(module, "_remote_is_empty", + lambda repository: asked.append(repository)) + code = module.main(["init", "--org", ORG, "--family", "Contoso", "--created-by", "Test Human", - "--work-dir", str(tmp_path / "work"), "--dry-run") - assert result.returncode == 0, result.stderr + "--work-dir", str(tmp_path / "work"), "--dry-run"]) + captured = capsys.readouterr() + assert code == 0, captured.err assert "topics gh repo edit --add-topic xf-project-contoso" \ - in result.stdout + in captured.out + assert asked == [f"{ORG}/Contoso"] assert not (tmp_path / "work").exists() diff --git a/tests/test_scaffold_e2e.py b/tests/test_scaffold_e2e.py index bbcee142..a5aaf55c 100644 --- a/tests/test_scaffold_e2e.py +++ b/tests/test_scaffold_e2e.py @@ -13,7 +13,7 @@ from conftest import (ORG, PROJECT, REPO, SCAFFOLD, WINDOWS_SKIP, assert_read_through_git, cloned_just_pushed, git, - run_script) + run_script, scaffold_dry_run_on_github) DEGRADE_LINE = "authority is not wallet-carried in this org" @@ -153,6 +153,7 @@ def test_a_neutral_product_root_is_planned_by_a_dry_run(tmp_path): result = run_script(SCAFFOLD, "--org", "Example", "--project", "openDox", "--elected-by", "Brett Heap", "--elected-on", "2026-09-05", "--dry-run", + "--local-remote-dir", str(tmp_path / "remotes"), "--work-dir", str(tmp_path / "work")) assert result.returncode == 0, result.stderr + result.stdout for leg in ("Example/openDox", "Example/openDox-spec", @@ -837,21 +838,24 @@ def test_the_default_visibility_is_private(project, monkeypatch): assert manifest["visibility"] == "private" -def test_internal_visibility_is_accepted(tmp_path, monkeypatch): +def test_internal_visibility_is_accepted(tmp_path, monkeypatch, capsys): """`internal` is a real GitHub visibility (an enterprise org-internal repository: `gh repo create --internal`, `gh repo view --json visibility` -> `INTERNAL`) — not a typo of `private`/`public`, and this standard must accept it. Local bare repositories have no visibility of their own, so what is checked is the printed plan and what the manifest records.""" remotes = tmp_path / "remotes" - # NOT --local-remote-dir here: `--dry-run` returns before any `gh` or - # `git` command runs, so this prints the REAL (non-local) plan line — - # `gh repo create --internal` — with no network involved at all. - dry = run_script(SCAFFOLD, "--org", ORG, "--project", "Fernwood", - "--elected-by", "Test Human", "--visibility", "internal", - "--dry-run", "--work-dir", str(tmp_path / "work")) - assert dry.returncode == 0, dry.stderr - assert "gh repo create --internal" in dry.stdout + # NOT --local-remote-dir here, so the plan carries the REAL (non-local) + # line, `gh repo create --internal`. The dry run asks GitHub whether the + # three exist (#537), so it runs in process and that one question is a + # stand-in's: no `gh` and no network. + code, out, err, asked = scaffold_dry_run_on_github( + monkeypatch, capsys, "--org", ORG, "--project", "Fernwood", + "--elected-by", "Test Human", "--visibility", "internal", + "--work-dir", str(tmp_path / "work")) + assert code == 0, err + assert "gh repo create --internal" in out + assert len(asked) == 3 result = run_script(SCAFFOLD, "--org", ORG, "--project", "Fernwood", "--elected-by", "Test Human", "--visibility", "internal", diff --git a/tests/test_scaffold_leg_path_collisions.py b/tests/test_scaffold_leg_path_collisions.py index 68ca4d36..591e0561 100644 --- a/tests/test_scaffold_leg_path_collisions.py +++ b/tests/test_scaffold_leg_path_collisions.py @@ -267,8 +267,9 @@ def test_the_remediation_lists_the_materializers_paths(scaffold, no_process, @pytest.mark.parametrize("value", CLEAR) def test_a_path_that_overlaps_nothing_the_scaffold_writes_plans( - scaffold, capsys, value): - code = scaffold.main([*BASE_ARGS, "--dry-run", "--spec-path", value]) + scaffold, capsys, tmp_path, value): + code = scaffold.main([*BASE_ARGS, "--dry-run", "--spec-path", value, + "--local-remote-dir", str(tmp_path / "remotes")]) out, err = capsys.readouterr() assert code == 0, out + err assert REFUSED not in err diff --git a/tests/test_scaffold_manifest_values.py b/tests/test_scaffold_manifest_values.py index 36bbe81f..4fdd1918 100644 --- a/tests/test_scaffold_manifest_values.py +++ b/tests/test_scaffold_manifest_values.py @@ -54,7 +54,7 @@ import pytest from conftest import (ORG, REPO, SCAFFOLD, WINDOWS_SKIP, cloned_just_pushed, - git, run_script) + git, run_script, scaffold_dry_run_on_github) sys.path.insert(0, str(REPO / "scripts")) import shape_materialize # noqa: E402 @@ -626,14 +626,19 @@ def test_a_project_whose_work_directory_name_is_too_long_is_refused_too( assert not (tmp_path / WORK).exists() -def test_the_longest_name_a_disk_keeps_is_still_accepted(tmp_path): +def test_the_longest_name_a_disk_keeps_is_still_accepted(tmp_path, monkeypatch, + capsys): """246 characters is `-spec.git` at exactly 255 bytes. A dry run - only: a real tree this deep is a Windows path the OS will not open.""" + only: a real tree this deep is a Windows path the OS will not open. + Without `--local-remote-dir` the dry run asks GitHub whether the three + exist (#537), so that half runs in process and asks a stand-in.""" result = scaffold(tmp_path, "--project", "N" * 246, "--dry-run") assert result.returncode == 0, result.stderr + result.stdout - without_remotes = scaffold(tmp_path, "--project", "N" * 250, "--dry-run", - local=False) - assert without_remotes.returncode == 0, without_remotes.stderr + code, _out, err, asked = scaffold_dry_run_on_github( + monkeypatch, capsys, *IDENTITY, "--project", "N" * 250) + assert code == 0, err + assert asked == [f"{ORG}/{'N' * 250}{tail}" for tail in + ("", "-spec", "-code")] def test_the_name_rule_is_the_same_bytes_the_tools_make(): diff --git a/tests/test_scaffold_pin_and_reuse.py b/tests/test_scaffold_pin_and_reuse.py index 166a26cb..b4bcbb10 100644 --- a/tests/test_scaffold_pin_and_reuse.py +++ b/tests/test_scaffold_pin_and_reuse.py @@ -580,6 +580,7 @@ def test_the_dry_run_plans_a_pinned_neutral_product_root(tmp_path): "--elected-on", "2026-09-05", "--dry-run", "--pin", f"openDox@{commit}", "--pin-source", str(product), + "--local-remote-dir", str(tmp_path / "remotes"), "--work-dir", str(tmp_path / "work")) assert result.returncode == 0, result.stderr + result.stdout for leg in ("Example/openXdox", "Example/openXdox-spec", diff --git a/tests/test_setup_project_py.py b/tests/test_setup_project_py.py index 95481f11..5fed9d9f 100644 --- a/tests/test_setup_project_py.py +++ b/tests/test_setup_project_py.py @@ -43,7 +43,8 @@ from conftest import (DISCARD_IT, DISCARD_IT_IF_UNUSED, KEEP_IT, TAKEN_CLONE_PATHS, assert_a_rehearsals_clone_stops_the_real_run, - assert_a_taken_clone_path_is_refused) + assert_a_taken_clone_path_is_refused, + assert_the_plan_refuses_a_remote_that_exists) SETUP_PROJECT = REPO / "setup-project.py" SETUP_SH = REPO / "setup.sh" @@ -480,6 +481,12 @@ def test_a_taken_clone_path_is_refused_before_anything(tmp_path, kind): assert_a_taken_clone_path_is_refused(run_entry, tmp_path, kind) +def test_the_plan_refuses_a_remote_that_exists_before_the_yes(tmp_path): + """#537, on an interpreter alone: `conftest.assert_the_plan_refuses_a_ + remote_that_exists`.""" + assert_the_plan_refuses_a_remote_that_exists(run_entry, tmp_path) + + def test_anything_but_yes_refuses_and_creates_nothing(monkeypatch, tmp_path): """The one `yes` is EXACT: `no`, `y`, `Yes` and an empty line all refuse. diff --git a/tests/test_setup_sh.py b/tests/test_setup_sh.py index 9e2c300f..b238072c 100644 --- a/tests/test_setup_sh.py +++ b/tests/test_setup_sh.py @@ -30,7 +30,8 @@ from conftest import (TAKEN_CLONE_PATHS, assert_a_rehearsals_clone_stops_the_real_run, - assert_a_taken_clone_path_is_refused) + assert_a_taken_clone_path_is_refused, + assert_the_plan_refuses_a_remote_that_exists) SETUP = REPO / "setup.sh" DEGRADE_LINE = "authority is not wallet-carried in this org" @@ -410,6 +411,12 @@ def test_a_taken_clone_path_is_refused_before_anything(tmp_path, kind): assert_a_taken_clone_path_is_refused(run_setup, tmp_path, kind) +def test_the_plan_refuses_a_remote_that_exists_before_the_yes(tmp_path): + """#537, through the shim: `conftest.assert_the_plan_refuses_a_ + remote_that_exists`.""" + assert_the_plan_refuses_a_remote_that_exists(run_setup, tmp_path) + + def test_no_test_here_depends_on_gh_being_authenticated(): """Every invocation either stops before the preflight or runs offline.