Skip to content
12 changes: 10 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
<org>/<Family> 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`
Expand Down
70 changes: 61 additions & 9 deletions scaffold-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -136,17 +138,41 @@
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 `<org>/<repo>` exist on GitHub with ZERO commits?

True: it exists and is empty. False: it exists and has commits. None: it
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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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

Expand Down
41 changes: 37 additions & 4 deletions scripts/family.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<org>/<Family>` 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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -4956,7 +4983,13 @@ def build_parser() -> argparse.ArgumentParser:
"<work-dir>/<Family> 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 <org>/<Family> 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(
Expand Down
61 changes: 61 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <args> --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."""
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading