diff --git a/README.md b/README.md index e8da9381..5465d135 100644 --- a/README.md +++ b/README.md @@ -305,9 +305,12 @@ flag goes on the Python entry point — which, run from a download, still clones the standard into a temporary directory first, and from a checkout clones nothing. What it leaves behind is `./Atlas` and three bare repositories in `./rehearsal`, both of which you can -delete. `openRepoShape --help` and `setup.sh --help` list every flag, this one -included; [`docs/cli.md`](docs/cli.md) is every tool's help and every -subcommand's in one file, generated from the tools themselves. +delete. `python3 setup-project.py --help` and, from a checkout, `setup.sh --help` +list every flag, this one included. From a download, `setup.sh --help` clones +nothing and lists none: it names `setup-project.py --help`, which answers +offline from that one file too, and [`docs/cli.md`](docs/cli.md), every +tool's help and every subcommand's in one file, generated from the tools +themselves. Without installing anything — no fork, no manual clone — one command: @@ -516,11 +519,11 @@ confers nothing and stays neutral. ### What setup.sh does -`setup.sh` is a shim — about seventy lines of code (twice that with its comments) -that find a Python 3.9 or newer, clone this standard into a temporary directory when -you are not already in a checkout of it, and hand over to `setup-project.py`, which -IS the flow. These are the commands that flow runs, in order, with no behaviour of -its own (the first only in self-bootstrap mode, from the one-liner above): +`setup.sh` is a shim that finds a Python 3.9 or newer, clones this standard into a +temporary directory when you are not already in a checkout of it, and hands over to +`setup-project.py`, which IS the flow. These are the commands that flow runs, in +order, with no behaviour of its own (the first only in self-bootstrap mode, from the +one-liner above): ```sh git clone --depth 1 https://github.com/opensoft/openRepoShape.git diff --git a/docs/cli.md b/docs/cli.md index 94b40038..1a152024 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -57,6 +57,8 @@ Every command below is typed at the root of a clone of this repository. `openRep ### `setup.sh` +From anywhere but a checkout of the standard — a download, `curl … | bash` — `setup.sh --help` is the shim's own instead: a few lines that clone nothing, list no flag, and name `setup-project.py --help` and this file as where the flags are. + `./setup.sh --help`: ``` @@ -133,8 +135,9 @@ and setup.sh asks, if a terminal is attached. `--org` comes from that flag, else $OPENREPOSHAPE_ORG, else a prompt — it is NEVER defaulted. Every other argument reaches setup.sh unchanged: --visibility, --elected-by, --family, --into, --yes, --local-remote-dir, and anything after `--`, which setup.sh -hands on to scaffold-project.py. `./setup.sh --help` prints setup-project.py's -usage, which lists them all. +hands on to scaffold-project.py. They are setup-project.py's flags: +`python3 setup-project.py --help` lists them all, and so does +`./setup.sh --help` in a checkout of this standard. `--install` places ONE command, and it is this one file: diff --git a/docs/handbook.html b/docs/handbook.html index 11b4688f..1baf4391 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -864,9 +864,12 @@

Rehearse first, creating nothing

instead and nothing touches the network at all.

Run from a download it still clones this standard into a temporary directory first; run from a checkout it clones nothing. - openRepoShape --help and setup.sh --help list every flag, this - one included; docs/cli.md is every tool’s help and every - subcommand’s in one file, generated from the tools themselves.

+ python3 setup-project.py --help and, from a checkout, + setup.sh --help list every flag, this one included. From a download, + setup.sh --help clones nothing and lists none: it names + setup-project.py --help, which answers offline from that one file too, + and docs/cli.md, every tool’s help and every subcommand’s in + one file, generated from the tools themselves.

  • @@ -929,11 +932,11 @@

    Consent rules, on every platform

    What setup.sh actually runs

    -

    It is a shim — about seventy lines of code (twice that with its comments) that find - a Python 3.9 or newer, clone this standard into a temporary directory when you are not - already in a checkout of it, and hand over to setup-project.py, which IS the - flow. These are the commands that flow runs, in order, with no behaviour of its own (the - first only in self-bootstrap mode, from the one-liner):

    +

    It is a shim that finds a Python 3.9 or newer, clones this standard into a temporary + directory when you are not already in a checkout of it, and hands over to + setup-project.py, which IS the flow. These are the commands that flow runs, + in order, with no behaviour of its own (the first only in self-bootstrap mode, from the + one-liner):

    git clone --depth 1 https://github.com/opensoft/openRepoShape.git <tmp-dir>
     python3 scripts/validate-repository-naming.py --explain Atlas Atlas-spec Atlas-code
     python3 scaffold-project.py --org <your-org> --project Atlas --dry-run
    @@ -1794,9 +1797,11 @@ 

    For AI assistants

    saying that some scaffold refusals name a value to change, added (#288); bump-leg.py refusing a workflow file or the leg’s pin that is not UTF-8, by name, added (#289); bump-leg.py refusing a workflow file, the workflows directory - or the leg’s pin that this user cannot open, by name, added (#383) — a page cannot - name the commit that contains it, and the blob below is the fact a test checks. - README.md blob e8da93817b0c1a195300800373a2895c0e0280e5. Every command, flag, exit code + or the leg’s pin that this user cannot open, by name, added (#383); what + setup.sh --help does from a download, which clones nothing, and which commands + list every flag, added (#341) — a page cannot name the commit that contains it, and + the blob below is the fact a test checks. + README.md blob 5465d135ca7263010ee354424041f90c4553d2e7. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

    diff --git a/openRepoShape b/openRepoShape index e7531166..0e91ef65 100755 --- a/openRepoShape +++ b/openRepoShape @@ -69,8 +69,9 @@ and setup.sh asks, if a terminal is attached. `--org` comes from that flag, else $OPENREPOSHAPE_ORG, else a prompt — it is NEVER defaulted. Every other argument reaches setup.sh unchanged: --visibility, --elected-by, --family, --into, --yes, --local-remote-dir, and anything after `--`, which setup.sh -hands on to scaffold-project.py. `./setup.sh --help` prints setup-project.py's -usage, which lists them all. +hands on to scaffold-project.py. They are setup-project.py's flags: +`python3 setup-project.py --help` lists them all, and so does +`./setup.sh --help` in a checkout of this standard. `--install` places ONE command, and it is this one file: diff --git a/scripts/render-cli-reference.py b/scripts/render-cli-reference.py index 81cae8d2..44772b71 100755 --- a/scripts/render-cli-reference.py +++ b/scripts/render-cli-reference.py @@ -44,6 +44,18 @@ as in `tests/test_repo_hygiene.py`, because this is the one file in the repository whose bytes come from running programs on somebody's machine. +A CHECKOUT OF THE STANDARD, AND NOTHING ELSE, IS RENDERED (#341). `setup.sh +--help` is `setup-project.py`'s usage only where the shim finds itself in a +checkout; anywhere else it answers with a short text of its own, and before +#341 it cloned the public upstream to answer at all. So a rendering made in a +download, a `git archive` or a release extraction reached GitHub and then +documented UPSTREAM's flags under this tree's heading, `--check` included. +`require_a_checkout` asks the shim's own question first and refuses, naming +the tree; and every child runs under `GIT_ALLOW_PROTOCOL=file`, so that if +the question ever stops matching the shim's, the clone fails closed instead +of reaching the network. A checkout clones nothing, so neither changes a +byte of `docs/cli.md`. + THE PROJECT-SIDE COPIES CANNOT PRINT THEIR HELP AS THEY STAND from a checkout of this standard. Each of the six does `sys.path.insert(0, )` and then `from repo_shape import …`, and `repo_shape.py` is beside @@ -108,7 +120,12 @@ class Tool(NamedTuple): Tool("setup.sh", "bash", "./setup.sh", "The front door for a NEW project: preflight, the naming check, the " "plan, one typed `yes`, then three repositories. A SHIM over " - "`setup-project.py`, so the help below is that tool's own."), + "`setup-project.py`, so the help below is that tool's own.", + note="From anywhere but a checkout of the standard — a download, " + "`curl … | bash` — `setup.sh --help` is the shim's own instead: " + "a few lines that clone nothing, list no flag, and name " + "`setup-project.py --help` and this file as where the flags " + "are."), Tool("openRepoShape", "bash", "openRepoShape", "The same front door as an installed command, plus `--install` " "(itself, into `~/.local/bin`), `--preflight` (this MACHINE) and " @@ -261,6 +278,13 @@ def child_env() -> dict: to depend on which locales happen to be installed on whichever machine runs this script next, which is exactly the noise this capture must not have on one platform and not another. + + `GIT_ALLOW_PROTOCOL=file` (#341), replacing whatever the caller had: + asking a tool for its help is never a network act, and no child here + clones anything from a checkout, which `require_a_checkout` insists on. + If that ever stops being true, git refuses the transport, the tool's + `--help` exits non-zero, and `capture_help` turns that into a refusal + naming the tool — the regression fails closed, offline. """ env = dict(os.environ) env.pop("LINES", None) @@ -275,10 +299,85 @@ def child_env() -> dict: "NO_COLOR": "1", "TERM": "dumb", "LC_ALL": "C", + "GIT_ALLOW_PROTOCOL": "file", }) return env +#: WHAT `setup.sh`'s `is_shape_checkout` ASKS FOR, read from the shim rather +#: than guessed: git's top level for the directory the shim sits in must hold +#: both of these files. `tests/test_cli_reference.py` reads the shim's probe +#: and fails if the two lists ever part. +SHAPE_CHECKOUT_FILES = ("scaffold-project.py", + "contracts/repository-naming.yaml") + +#: The second half of every refusal `require_a_checkout` makes. +RENDER_FROM_A_CLONE = ( + "Outside a checkout `setup.sh --help` is the shim's own short text, not " + "`setup-project.py`'s usage, so a rendering here would document the " + "wrong thing. Run this script in a `git clone` of the standard instead.") + + +def require_a_checkout(tree: Path = REPO) -> None: + """Refuse, naming `tree`, unless it is the top level of a checkout. + + THE SHIM'S OWN QUESTION, asked first: `git -C rev-parse + --show-toplevel` answers, and that top level holds every one of + `SHAPE_CHECKOUT_FILES`. Where it does not, `setup.sh --help` is not + `setup-project.py`'s usage, which is the text this script renders. + + AND ONE QUESTION THE SHIM DOES NOT ASK: that the top level IS `tree`. A + copy of the standard nested inside another checkout of it passes the + shim's probe, and its `setup.sh --help` then prints the OUTER checkout's + `setup-project.py` usage — somebody else's flags under this tree's + heading. `os.path.samefile`, because git spells the path its own way + (forward slashes on Windows, a symlink resolved). + """ + try: + proc = subprocess.run( + ["git", "-C", str(tree), "rev-parse", "--show-toplevel"], + env=child_env(), stdin=subprocess.DEVNULL, capture_output=True, + check=False) + except OSError as error: + raise Refusal( + f"cannot tell whether {tree} is a checkout of openRepoShape: git " + f"could not be run ({error}). {RENDER_FROM_A_CLONE}") from error + # THE PATH, BYTE FOR BYTE. Bash's `$(...)` in the shim's + # `is_shape_checkout` drops the trailing newlines and keeps every other + # byte, so a checkout whose directory name ends in a space, or is not + # UTF-8, is a checkout to the shim and has to be one here. `.strip()` + # would eat the space that belongs to the name, and a `replace` decode + # would turn a byte that is not UTF-8 into U+FFFD, naming a directory that + # is not there. `os.fsdecode` is how this process spells such a name to + # the file system; the `\r` goes with the newline, as `.strip()` took it, + # for a git that ends its lines the Windows way. + toplevel = os.fsdecode(proc.stdout).rstrip("\r\n") + if proc.returncode != 0 or not toplevel: + said = proc.stderr.decode("utf-8", "replace").strip().splitlines() + raise Refusal( + f"{tree} is not a checkout of openRepoShape: git finds no work " + f"tree there (a download, or a `git archive` or release " + f"extraction, has none){'; git said: ' + said[0] if said else ''}" + f". {RENDER_FROM_A_CLONE}") + missing = [name for name in SHAPE_CHECKOUT_FILES + if not (Path(toplevel) / name).is_file()] + if missing: + raise Refusal( + f"{tree} is not a checkout of openRepoShape: it lies in the git " + f"work tree {toplevel}, whose top level has no " + f"{' and no '.join(missing)}. {RENDER_FROM_A_CLONE}") + try: + same = os.path.samefile(toplevel, tree) + except OSError: + same = False + if not same: + raise Refusal( + f"{tree} is not the top level of a checkout of openRepoShape: it " + f"lies inside the checkout {toplevel}, so its `setup.sh --help` " + f"would print THAT checkout's `setup-project.py` usage. " + f"{RENDER_FROM_A_CLONE}") + + def capture_help(tool: Tool, *subcommand: str) -> str: """One tool's `--help`, normalised. @@ -548,7 +647,13 @@ def _refuse_host_paths(text: str) -> None: def render(skip_bash: bool = False) -> str: - """The whole document.""" + """The whole document. + + `require_a_checkout` first, here as well as in `main`: the guard in + `tests/test_cli_reference.py` calls this directly, and a test suite run + in an extraction is a rendering made in one. + """ + require_a_checkout() tools = [(title, _active_tools(group, skip_bash), blurb) for title, group, blurb in GROUPS] @@ -617,6 +722,15 @@ def main(argv: list | None = None) -> int: "there and write nothing; exit 1 if they differ") args = parser.parse_args(argv) + # A TREE THAT IS NOT A CHECKOUT IS REFUSED FIRST (#341), before the + # questions about this machine below: it is the same refusal on every + # machine, and a windows runner should hear it rather than the bash one. + try: + require_a_checkout() + except Refusal as refusal: + print(f"[!!] {refusal}", file=sys.stderr) + return 2 + # `os.name` BEFORE `shutil.which`, and the order is the point: # `tests/conftest.py::WINDOWS_SKIP` says at length that the `bash` on a # stock Windows PATH is `C:\Windows\System32\bash.exe`, the WSL diff --git a/setup-project.py b/setup-project.py index 49b5f0db..be7d44cd 100755 --- a/setup-project.py +++ b/setup-project.py @@ -33,11 +33,11 @@ Two entry points that refuse different things in different words are two standards, so #50 settled which of them is the flow. `setup.sh` is a shim now: -about seventy lines of code (twice that with its comments) that find an -interpreter, clone this standard when the person has no checkout of it, and -hand over here. There is ONE parser, one usage banner and one set of refusal -wordings, and `tests/test_setup_sh.py` drives them through bash where -`tests/test_setup_project_py.py` drives them through an interpreter alone. +it finds an interpreter, clones this standard when the person has no checkout +of it, and hands over here. There is ONE parser, one usage banner and one set +of refusal wordings, and `tests/test_setup_sh.py` drives them through bash +where `tests/test_setup_project_py.py` drives them through an interpreter +alone. THE `setup.sh:NNN` CITATIONS BELOW NAME setup.sh AS IT STOOD BEFORE #50, the 500-line implementation this file was transcribed from. They are kept because diff --git a/setup.sh b/setup.sh index 25e93915..034fdbcb 100755 --- a/setup.sh +++ b/setup.sh @@ -9,11 +9,11 @@ # THIS FILE IS NOT THE FLOW; `setup-project.py` is. The preflight, the # organisation, the naming policy, the plan, the one yes, the scaffold, the # clone and the bootstrap all live there — one implementation, one flag list, -# one set of refusal wordings, one `--help`. This script parses nothing, and -# owns exactly four things, each of them something Python cannot do for -# itself before it is running: an INTERPRETER (3.9 or newer, under whichever -# name this machine has one); GIT itself, because the checkout probe below -# asks git a question before Python is ever started; a CHECKOUT when the +# one set of refusal wordings, one `--help`. This script parses nothing of +# the RUN, and owns exactly four things, each of them something Python cannot +# do for itself before it is running: an INTERPRETER (3.9 or newer, under +# whichever name this machine has one); GIT itself, because the checkout probe +# below asks git a question before Python is ever started; a CHECKOUT when the # person has none, because `curl … | bash` leaves no file on disk at all and # this script is then the whole of what they have; and the INVOCATION # DIRECTORY, which that checkout would otherwise lose — handed over as @@ -21,14 +21,19 @@ # beside a temporary checkout in /tmp (#39). # # What follows is a bash transcription of `setup-project.py`'s section 0 -# (`self_bootstrap`), and nothing else. Every flag named below is about the -# checkout THIS FILE makes, never about the run. +# (`self_bootstrap`), and nothing else. Section 0 reads the command line once, +# for the three things that decide the checkout THIS FILE makes: whether to +# make one at all (`-h`/`--help`, which outside a checkout this script answers +# ITSELF, in a few lines that list no flag, before any clone — #341), which +# ref (`--shape-ref`), and whether to keep it (`--keep-shape-checkout`). +# Inside a checkout it reads nothing, and `--help` is `setup-project.py`'s. set -euo pipefail INVOCATION_DIR="$PWD" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd)" -SHAPE_REPO="${OPENREPOSHAPE_REPO:-https://github.com/opensoft/openRepoShape.git}" +DEFAULT_SHAPE_REPO="https://github.com/opensoft/openRepoShape.git" +SHAPE_REPO="${OPENREPOSHAPE_REPO:-$DEFAULT_SHAPE_REPO}" ENTRY="setup-project.py" say() { printf '%s\n' "$*"; } @@ -116,38 +121,94 @@ fi # -------------------------------------------------------------------------- # 0. self-bootstrap: there is no checkout, so make one # -------------------------------------------------------------------------- -# $OPENREPOSHAPE_REPO reaches `git clone`, which reads its own arguments: a -# value starting with `-` is an option to git rather than a repository. A -# CONTROL CHARACTER is refused too — a newline in this value could put a -# forged line on stdout or stderr before this script has said a word of its -# own, which is why the refusal below does not echo the value back. The `--` -# below guards the argv too; this is what names the fault instead of leaving -# a confusing git error. -case "$SHAPE_REPO" in --*|*[![:print:]]*) die "OPENREPOSHAPE_REPO is not a value this tool will put on a \`git clone\` command line: it starts with '-' or carries a control character. Set it to a URL or a path." ;; -*) ;; # a well-formed value; passed to `git clone` unchanged below. -esac - -# THE ONLY READ OF THE COMMAND LINE, AND IT CONSUMES NOTHING. Both flags are +# THE ONLY READ OF THE COMMAND LINE, AND IT CONSUMES NOTHING. All three are # about the checkout made here, and every argument is passed on to Python -# unchanged whatever this scan concludes — which is what keeps one parser. +# unchanged whatever this scan concludes — which is what keeps one parser — +# bar one run that is not passed on at all: a request for help, answered +# here (below). # TWO KNOWN FALSE POSITIVES, both harmless: a VALUE spelled # `--keep-shape-checkout` sets that flag, and a VALUE spelled `--shape-ref` # makes the WORD AFTER IT become $SHAPE_REF. Both runs are refused anyway by # Python's `checked_value`, which rejects that leading `-` before anything is -# created — this scan only decides whether a temporary directory is removed -# and whether a ref gets checked out, never what the run itself does. Each -# `case` below ends `*) ;;` for the same reason: an argument this scan does -# not recognize is Python's to read, not this script's to reject. +# created — the first two reads only decide whether a temporary directory is +# removed and whether a ref gets checked out, never what the run itself does, +# and the third decides whether there is a run at all (below). Each `case` +# below ends `*) ;;` because an argument this scan does not recognize is +# Python's to read, not this script's to reject. +# +# `-h` AND `--help` BOTH, because `setup-project.py` reads both as the help, +# and only BEFORE `--`: what follows `--` is `scaffold-project.py`'s, so +# `setup.sh Atlas -- --help` asks the scaffold for ITS help and is a run like +# any other. Its false positives are runs `setup-project.py` would refuse — +# `--help` as a flag's VALUE (`--org --help`; a value starting with `-` is +# refused) or after an argument it refuses (`--wat --help`) — which get the +# text below and exit 0 instead; nothing is created either way. Its one false +# NEGATIVE is `--` as `--visibility`'s value (`--visibility -- --help`), read +# here as the end of the options: that run clones, as every run did before. +ASKED_FOR_HELP=0 KEEP_SHAPE_CHECKOUT=0 SHAPE_REF="" prev="" for arg in ${1+"$@"}; do case "$arg" in --) break ;; *) ;; esac case "$prev" in --shape-ref) SHAPE_REF="$arg" ;; *) ;; esac - case "$arg" in --keep-shape-checkout) KEEP_SHAPE_CHECKOUT=1 ;; *) ;; esac + case "$arg" in + -h|--help) ASKED_FOR_HELP=1 ;; + --keep-shape-checkout) KEEP_SHAPE_CHECKOUT=1 ;; + *) ;; # Python's to read; see above. + esac prev="$arg" done + +# HELP IS ANSWERED HERE, FIRST, AND WITH NO CLONE (#341). It used to fall +# through to the clone below like any other run: a request for help from a +# download cloned the public upstream before printing a word, died with git's +# exit 128 offline, and printed the CLONE's usage, never anything beside this +# file. `setup-project.py` answers `-h`/`--help` before it looks at anything +# — the machine, the checkout, `$OPENREPOSHAPE_REPO` — so this does too: +# before that variable's guard, before `mktemp`, before `git clone`. +# +# THE TEXT LISTS NO FLAG, so there is still one flag list (#50): it is +# `setup-project.py --help`, which answers offline from a checkout or from +# that one file, and `docs/cli.md`. It names `$OPENREPOSHAPE_REPO` and its +# DEFAULT, never the variable's value — that guard has not run yet, and a +# value is echoed only after its guard has passed. +shim_help() { + say "setup.sh: the bash front door to setup-project.py, which is the flow itself." + say "" + say "This copy of setup.sh is not inside a checkout of openRepoShape, so it" + say "answers --help on its own: nothing was cloned and nothing was created." + say "Any other run from here first clones the standard into a temporary directory" + say "and hands over to setup-project.py in that checkout. The clone is of" + say "\$OPENREPOSHAPE_REPO, or of this when that is unset or empty:" + say "" + say " $DEFAULT_SHAPE_REPO" + say "" + say "The flags are setup-project.py's, and its own --help is the one list of" + say "them. It answers with no network, from a checkout of openRepoShape or" + say "from that one file downloaded on its own:" + say "" + say " python3 setup-project.py --help" + say "" + say "docs/cli.md in the repository is the same list, beside every other tool's." +} +if [[ "$ASKED_FOR_HELP" -eq 1 ]]; then + shim_help + exit 0 +fi + +# $OPENREPOSHAPE_REPO reaches `git clone`, which reads its own arguments: a +# value starting with `-` is an option to git rather than a repository. A +# CONTROL CHARACTER is refused too — a newline in this value could put a +# forged line on stdout or stderr before this script has said a word of its +# own, which is why the refusal below does not echo the value back. The `--` +# below guards the argv too; this is what names the fault instead of leaving +# a confusing git error. +case "$SHAPE_REPO" in +-*|*[![:print:]]*) die "OPENREPOSHAPE_REPO is not a value this tool will put on a \`git clone\` command line: it starts with '-' or carries a control character. Set it to a URL or a path." ;; +*) ;; # a well-formed value; passed to `git clone` unchanged below. +esac + # Mirrors `setup-project.py`'s REF_RE: a ref starts with a letter or digit, # holds only letters, digits, '.', '_', '/', '-', is never a `..` range, # never ends in `.lock` (git's own lock-file name for one), and is at most diff --git a/tests/test_cli_reference.py b/tests/test_cli_reference.py index 5b8db755..45fac457 100644 --- a/tests/test_cli_reference.py +++ b/tests/test_cli_reference.py @@ -41,10 +41,15 @@ import ast import difflib import importlib.util +import os +import re +import shutil +import subprocess +import sys import pytest -from conftest import REPO, WINDOWS_SKIP +from conftest import REPO, WINDOWS_SKIP, git REFERENCE = REPO / "docs" / "cli.md" GENERATOR = REPO / "scripts" / "render-cli-reference.py" @@ -72,15 +77,16 @@ NOT_A_CLI_OF_THE_STANDARD = {"scripts/render-cli-reference.py"} -def generator_module(): +def generator_module(path=GENERATOR): """`scripts/render-cli-reference.py` imported as a module. The filename has a hyphen and cannot be imported by name; `tests/test_repo_hygiene.py::entry_point_module` does the same for - `setup-project.py` for the same reason. + `setup-project.py` for the same reason. `path` is for a COPY of the + generator, whose `REPO` is then the copy's tree. """ spec = importlib.util.spec_from_file_location("render_cli_reference", - GENERATOR) + path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module @@ -404,3 +410,228 @@ def test_the_generators_own_check_agrees_with_this_suite(): assert module.main(["--check"]) == 0, ( f"`{REGENERATE} --check` disagrees with this suite; one of the two " "is wrong") + + +# --- a tree that is not a checkout (#341) ------------------------------------ +# +# `setup.sh --help` is `setup-project.py`'s usage only inside a checkout of the +# standard. Outside one it is the shim's own short text, and before #341 the +# shim cloned the public upstream to answer at all — so the generator, run in +# a `git archive` or release extraction, reached GitHub and then rendered +# UPSTREAM's usage as that tree's `setup.sh` section. It now refuses such a +# tree before it runs anything, and every child it starts runs under +# `GIT_ALLOW_PROTOCOL=file`. Every run below sets that variable too, so that a +# regression fails closed rather than reaching the network. + +def copy_of_the_tree(destination): + """Every file git tracks here, copied with no `.git`: the tree a `git + archive` extraction holds, but taken from the WORKING TREE, so that it + carries the generator under test and not whatever HEAD commits.""" + listed = subprocess.run(["git", "ls-files", "-z"], cwd=str(REPO), + capture_output=True, check=True).stdout + for name in listed.decode("utf-8").split("\0"): + source = REPO / name + if name and source.is_file(): + target = destination / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + return destination + + +def offline_env(tmp_path): + """No network, no clone unrecorded, no git search above `tmp_path`. + + `GIT_CEILING_DIRECTORIES` keeps a basetemp that happens to sit inside a + git work tree from turning the plain copy into part of it. + `$OPENREPOSHAPE_REPO` is removed, so a regression would reach for the + DEFAULT upstream and be refused by `GIT_ALLOW_PROTOCOL` — the measure + itself, rather than a local clone that happened to succeed.""" + env = dict(os.environ) + env.pop("OPENREPOSHAPE_REPO", None) + tmpdir = tmp_path / "tmpdir" + tmpdir.mkdir(exist_ok=True) + env.update({"GIT_ALLOW_PROTOCOL": "file", + "GIT_CEILING_DIRECTORIES": str(tmp_path), + "GIT_TRACE": str(tmp_path / "git-trace.log"), + "TMPDIR": str(tmpdir)}) + return env + + +def traced_clones(tmp_path) -> list: + trace = tmp_path / "git-trace.log" + lines = (trace.read_text(encoding="utf-8", errors="replace").splitlines() + if trace.exists() else []) + return [line for line in lines if "git clone" in line] + + +def test_child_env_never_lets_a_child_reach_the_network(monkeypatch): + """`GIT_ALLOW_PROTOCOL=file` in every child, whatever the caller had: + asking a tool for its help is never a network act, so a child that + tried one fails closed and `capture_help` names it.""" + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "file:https:ssh") + assert generator_module().child_env()["GIT_ALLOW_PROTOCOL"] == "file" + + +def test_the_generators_checkout_test_is_the_shims_own(): + """The question `require_a_checkout` asks is `setup.sh`'s + `is_shape_checkout`, read from the shim and not guessed: the top level + git names, and the same files at it. If the shim's probe ever changes, + the generator's has to follow, or it renders trees the shim does not + treat as checkouts again.""" + source = (REPO / "setup.sh").read_text(encoding="utf-8") + probe = re.search(r"^is_shape_checkout\(\) \{\n(.*?)^\}", source, + re.MULTILINE | re.DOTALL) + assert probe, "setup.sh no longer defines is_shape_checkout()" + assert "rev-parse --show-toplevel" in probe.group(1) + files = set(re.findall(r'-f "\$toplevel/([^"]+)"', probe.group(1))) + assert files == set(generator_module().SHAPE_CHECKOUT_FILES), ( + f"setup.sh's is_shape_checkout asks for {sorted(files)}; the " + f"generator asks for {sorted(generator_module().SHAPE_CHECKOUT_FILES)}") + + +@pytest.mark.parametrize("layout,reason", [ + pytest.param("plain", "git finds no work tree there", id="no-git-at-all"), + pytest.param("in-a-work-tree", "whose top level has no", + id="inside-another-work-tree"), + pytest.param("in-a-checkout", "is not the top level of a checkout", + id="inside-a-checkout-of-the-standard"), +]) +def test_the_generator_refuses_a_tree_that_is_not_a_checkout(tmp_path, layout, + reason): + """`--check`, and a plain write, both refused — exit 2, the tree named, + nothing written, no clone made. + + Three trees. A plain copy with no `.git`, which is what a download or an + extraction is. A copy inside an unrelated git work tree, where git + answers with a top level that is not a checkout of the standard. And a + copy inside a work tree whose top level DOES look like one: the shim's + probe passes there, and its `setup.sh --help` would print THAT top + level's `setup-project.py` usage under this tree's heading, which is why + the generator also asks that the top level be the tree itself.""" + if layout == "plain": + tree = copy_of_the_tree(tmp_path / "tree") + else: + outer = tmp_path / "outer" + outer.mkdir() + git("init", "-q", cwd=outer) + if layout == "in-a-checkout": + (outer / "contracts").mkdir() + (outer / "scaffold-project.py").write_bytes(b"") + (outer / "contracts" / "repository-naming.yaml").write_bytes(b"") + tree = copy_of_the_tree(outer / "tree") + reference = (tree / "docs" / "cli.md").read_bytes() + env = offline_env(tmp_path) + + for argv in (["--check"], []): + result = subprocess.run( + [sys.executable, str(tree / "scripts" / "render-cli-reference.py"), + *argv], cwd=str(tree), env=env, stdin=subprocess.DEVNULL, + capture_output=True, check=False) + stderr = result.stderr.decode("utf-8", "replace") + said = stderr + result.stdout.decode("utf-8", "replace") + assert result.returncode == 2, f"{argv}: exit {result.returncode}\n{said}" + assert str(tree.resolve()) in stderr, said + assert reason in stderr, said + assert "git clone" in stderr, ( + f"the refusal does not say what to do instead:\n{said}") + assert "(0) self-bootstrap" not in said + assert (tree / "docs" / "cli.md").read_bytes() == reference, ( + "a refused run wrote docs/cli.md") + assert not traced_clones(tmp_path), traced_clones(tmp_path) + assert not list((tmp_path / "tmpdir").iterdir()) + + +def test_render_itself_refuses_a_tree_that_is_not_a_checkout(tmp_path, + monkeypatch): + """`render()`, not only `main()`: the byte-equality test above calls it + directly, so a suite run in an extraction is a rendering made in one.""" + tree = copy_of_the_tree(tmp_path / "tree") + for name, value in offline_env(tmp_path).items(): + monkeypatch.setenv(name, value) + monkeypatch.delenv("OPENREPOSHAPE_REPO", raising=False) + module = generator_module(tree / "scripts" / "render-cli-reference.py") + assert module.REPO == tree.resolve() + with pytest.raises(module.Refusal, match="is not a checkout"): + module.render() + assert not traced_clones(tmp_path), traced_clones(tmp_path) + + +def test_the_checkout_refusal_comes_before_the_no_bash_one(tmp_path, + monkeypatch, + capsys): + """Where there is no bash (the windows runner), `--check` stops with exit + 3 before it renders anything, and a write without `--skip-bash` with + exit 2 naming that flag. Neither is what is wrong with a tree that is + not a checkout, on any machine, so `main` asks about the tree FIRST. + `shutil.which` is monkeypatched, as the no-bash test above does it, so + this runs on every platform.""" + tree = copy_of_the_tree(tmp_path / "tree") + for name, value in offline_env(tmp_path).items(): + monkeypatch.setenv(name, value) + monkeypatch.delenv("OPENREPOSHAPE_REPO", raising=False) + module = generator_module(tree / "scripts" / "render-cli-reference.py") + monkeypatch.setattr(module.shutil, "which", lambda name: None) + for argv in (["--check"], [], ["--skip-bash"]): + assert module.main(argv) == 2, argv + err = capsys.readouterr().err + assert "is not a checkout" in err, f"{argv}: {err}" + assert "bash entry points" not in err, ( + f"{argv}: the no-bash refusal came first: {err}") + + +# --- checkouts the shim accepts and the generator must too (#341) ------------ +# +# The generator's probe is the shim's, so it has to read git's answer the way +# the shim does. Bash's `$(...)` keeps every byte of the top level but the +# trailing newlines; a generator that stripped the path, or decoded it with +# `replace`, refused two real checkouts the shim treats as checkouts: one whose +# directory name ends in a space, and one whose path is not UTF-8. Both tests +# are skipped where the file system cannot hold the name (Windows takes +# neither; macOS refuses what is not UTF-8), because there is then no such +# checkout to refuse. + +def a_checkout_at(path): + """`copy_of_the_tree`, plus the `git init` that makes it a work tree. + + The shim's probe, and the generator's, ask for a top level and two files at + it, so an empty repository is a checkout to both: no commit is needed.""" + copy_of_the_tree(path) + subprocess.run(["git", "init", "-q"], cwd=str(path), check=True, + stdin=subprocess.DEVNULL, capture_output=True) + return path + + +def the_generator_accepts(tree, tmp_path): + """`--check` IN `tree`, as a person runs it: it renders and agrees with the + committed reference (the copy carries this tree's own `docs/cli.md`), exit + 0, with no refusal and no clone.""" + result = subprocess.run( + [sys.executable, str(tree / "scripts" / "render-cli-reference.py"), + "--check"], cwd=str(tree), env=offline_env(tmp_path), + stdin=subprocess.DEVNULL, capture_output=True, check=False) + said = (result.stderr + result.stdout).decode("utf-8", "replace") + assert result.returncode == 0, f"exit {result.returncode}\n{said}" + assert "[ok]" in said, said + assert "is not a checkout" not in said, said + assert not traced_clones(tmp_path), traced_clones(tmp_path) + + +@WINDOWS_SKIP +def test_a_checkout_whose_path_ends_in_a_space_is_a_checkout(tmp_path): + tree = a_checkout_at(tmp_path / "trail" / "openRepoShape ") + assert str(tree).endswith(" "), "the fixture lost the space it is about" + the_generator_accepts(tree, tmp_path) + + +@WINDOWS_SKIP +def test_a_checkout_whose_path_is_not_utf8_is_a_checkout(tmp_path): + parent = tmp_path / os.fsdecode(b"bad\xffname") + try: + parent.mkdir() + except OSError as error: + pytest.skip(f"this file system cannot hold a name that is not UTF-8: " + f"{error}") + tree = a_checkout_at(parent / "openRepoShape") + with pytest.raises(UnicodeDecodeError): + os.fsencode(tree).decode("utf-8") + the_generator_accepts(tree, tmp_path) diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 40ac16ee..a281a729 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -1161,8 +1161,16 @@ def test_readme_is_short_enough_to_be_read(): # before anything is created. The README's own text had said only what # a `--pin` writes, so a reader met either refusal by running the # scaffold. - assert len(lines) <= 1419, ( - f"README.md is {len(lines)} lines; the cap is 1419") + # 2026-10-12: 1419 -> 1422 — `setup.sh --help` from a download (#341). + # The sentence that sent a reader to `setup.sh --help` for every flag + # now says that holds from a checkout, and what the same command does + # from a download: it clones nothing, lists no flag, and names + # `setup-project.py --help` and `docs/cli.md`. Three lines, because the + # shim's answer there changed from a clone of the upstream to a few + # lines of its own, and a README that still promised the flag list + # would send a reader to a command that no longer prints it. + assert len(lines) <= 1422, ( + f"README.md is {len(lines)} lines; the cap is 1422") @pytest.mark.parametrize("name", SHIPPED_BASH) diff --git a/tests/test_setup_project_py.py b/tests/test_setup_project_py.py index a65ba2d9..7945b9cd 100644 --- a/tests/test_setup_project_py.py +++ b/tests/test_setup_project_py.py @@ -1570,9 +1570,13 @@ def test_a_rehearsal_that_stops_at_the_plan_makes_no_family_folder(tmp_path): #: (`--quiet`, `--depth`), it may pin a ref (`--shape-ref`), it owns the #: directory it cloned into (`--keep-shape-checkout`), it asks git one #: question (`--show-toplevel`), and it tells the child where the person was -#: standing (`--into`). +#: standing (`--into`). And `--help` since #341, which decides whether there +#: is a checkout to make AT ALL: outside one, the shim answers it itself with +#: a few lines that list no flag, rather than cloning the standard to print +#: this file's usage. THE EXCUSE IS FOR TWO PLACES, NOT FOR THE SPELLING: +#: those few lines and the one case label, both pinned below. SHIM_FLAGS = {"--quiet", "--depth", "--show-toplevel", "--into", "--shape-ref", - "--keep-shape-checkout"} + "--keep-shape-checkout", "--help"} #: THE ONE SPELLING IN `setup.sh` THAT LOOKS LIKE A FLAG AND IS NOT ONE. #: `xcode-select --install` is APPLE'S command, inside the Darwin sentence @@ -1632,10 +1636,37 @@ def test_setup_sh_parses_no_flags_of_its_own(): "setup.sh names --install somewhere other than inside " "`xcode-select --install`; SHIM_TEXT_ONLY excuses that spelling only " "as Apple's command quoted in a message") - for label in ("-y)", "-h)", "--yes)", "--help)"): + for label in ("-y)", "-h)", "--yes)"): assert label not in code, ( f"setup.sh defines a case label {label!r} - a parser growing " "back one flag at a time") + # THE HELP IS THE ONE LABEL ALLOWED (#341), spelled exactly once and + # exactly so: section 0's scan answers `-h` and `--help` together + # outside a checkout, before any clone. A second `--help)` anywhere, or + # a help label of another shape, is a parser growing back after all. + assert code.count("--help)") == code.count("-h|--help)") == 1, ( + "setup.sh's one help label must be section 0's `-h|--help)`, once") + # ...AND EVERY OTHER `--help` IS THE SHIM'S OWN TEXT, which is `say` + # lines and nothing else. `SHIM_FLAGS` excuses a SPELLING, and a second + # reader needs no case label to be one: a quoted `"--help")`, a + # `[[ $1 == --help ]]` in the developer path or inside the scan, or a + # `--help` read from a command substitution inside a `say`. So the + # spelling is excused in exactly two places, the text and the label, and + # the text is held to one double-quoted string per line, with no command + # substitution, no `${...}` and no backtick in it. + text = re.search(r"^shim_help\(\) \{\n(.*?)^\}\n", code, + re.MULTILINE | re.DOTALL) + assert text, "setup.sh no longer defines shim_help()" + prints_only = re.compile(r'say "(?:[^"\\`$]|\\.|\$(?![({]))*"') + assert all(prints_only.fullmatch(line.strip()) + for line in text.group(1).splitlines()), ( + "shim_help() does something other than print its text: every line " + "must be one `say \"...\"` with no command substitution in it") + rest = code.replace(text.group(0), "") + assert rest.count("--help") == rest.count("-h|--help)") == 1, ( + "setup.sh reads --help somewhere other than section 0's one label") + assert len(re.findall(r"(? Path: def run_bare_setup(script: Path, *args: str, cwd: Path, - extra_env: dict | None = None) -> subprocess.CompletedProcess: + extra_env: dict | None = None, + stdin: str = "") -> subprocess.CompletedProcess: """Like `run_setup`, but against an arbitrary script path and cwd — what the self-bootstrap tests need since `run_setup` always drives THIS - checkout's own `setup.sh` from THIS checkout's own directory.""" + checkout's own `setup.sh` from THIS checkout's own directory. + + `stdin` as `run_setup` has it: a pipe, never the terminal pytest was + started from, so a run that reaches the one `yes` refuses instead of + waiting for somebody to type it.""" env = dict(os.environ) env.setdefault("GIT_AUTHOR_NAME", "openRepoShape tests") env.setdefault("GIT_AUTHOR_EMAIL", "tests@openreposhape.invalid") @@ -94,7 +99,35 @@ def run_bare_setup(script: Path, *args: str, cwd: Path, env.setdefault("GIT_COMMITTER_EMAIL", "tests@openreposhape.invalid") env.update(extra_env or {}) return subprocess.run(["bash", str(script), *args], capture_output=True, - text=True, check=False, cwd=str(cwd), env=env) + text=True, check=False, input=stdin, cwd=str(cwd), + env=env) + + +def shim_default_repo() -> str: + """What `setup.sh` clones when `$OPENREPOSHAPE_REPO` is unset or empty, + read from the shim's own source. + + ONE CONSTANT, `DEFAULT_SHAPE_REPO` (the name `setup-project.py` gives the + same value), because two things read it since #341: the clone, and the + shim's own `--help` outside a checkout, which names it. The second + search is what keeps the value the CLONE's default, rather than merely a + string the help happens to print. + """ + source = SETUP.read_text(encoding="utf-8") + default = re.search(r'^DEFAULT_SHAPE_REPO="([^"$]+)"$', source, + re.MULTILINE) + assert default, "DEFAULT_SHAPE_REPO not found in setup.sh" + assert re.search( + r'^SHAPE_REPO="\$\{OPENREPOSHAPE_REPO:-\$DEFAULT_SHAPE_REPO\}"$', + source, re.MULTILINE), ( + "setup.sh's SHAPE_REPO no longer falls back to DEFAULT_SHAPE_REPO") + return default.group(1) + + +#: The first line of the shim's OWN `--help`, printed only outside a +#: checkout (#341). Inside one, `--help` is `setup-project.py`'s usage. +SHIM_HELP_FIRST_LINE = ("setup.sh: the bash front door to setup-project.py, " + "which is the flow itself.") @pytest.fixture(scope="module") @@ -190,6 +223,28 @@ def test_help_exits_zero(): assert "usage: setup-project.py" in result.stdout +def test_help_inside_a_checkout_is_still_setup_project_pys_own(monkeypatch): + """#341 changed `--help` only OUTSIDE a checkout. Inside one the shim + still execs `setup-project.py` before section 0 reads a word, so `-h` + and `--help` both print that file's usage byte for byte, on stdout AND on + stderr, and never the shim's own text. Stderr too, because a second + reader of `--help` in the developer path would most naturally say its + piece there, where a stdout comparison cannot see it. + `GIT_ALLOW_PROTOCOL=file`, so a regression that reached the clone fails + closed instead of reaching GitHub.""" + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "file") + own = subprocess.run([sys.executable, str(REPO / "setup-project.py"), + "--help"], capture_output=True, text=True, + check=False) + assert own.returncode == 0, own.stderr + for result in (run_setup("--help"), run_setup("-h")): + assert result.returncode == 0, result.stderr + result.stdout + assert result.stdout == own.stdout + assert result.stderr == own.stderr + assert SHIM_HELP_FIRST_LINE not in result.stdout + assert "(0) self-bootstrap" not in result.stdout + + def test_an_unknown_argument_refuses(): result = run_setup("--project", "Sample", "--wat") assert result.returncode == 2 @@ -326,15 +381,21 @@ def test_passthrough_reaches_the_scaffold(tmp_path): def test_no_test_here_depends_on_gh_being_authenticated(): """Every invocation either stops before the preflight or runs offline. - The three that stop early are argument-parsing refusals (`--help`, an - unknown flag, a bad `--visibility`), which the script answers before it - looks at the machine. Everything else must pass `--local-remote-dir`, or it - reaches `gh auth status` and passes only on a laptop that happens to be - logged in — which is exactly how the first version of this file went green - locally and red in CI. + The ones that stop early are the help (`--help`, `-h`) and two + argument-parsing refusals (an unknown flag, a bad `--visibility`). Every + `run_setup` runs THIS checkout's `setup.sh` from inside THIS checkout, + where the shim hands all of them to `setup-project.py`, which answers + them before it looks at the machine. From anywhere ELSE, which only the + self-bootstrap section below reaches (through `run_bare_setup`, never + scanned here), the shim answers the help itself before any clone (#341), + and the two refusals are made by the CLONED `setup-project.py`. + Everything else must pass `--local-remote-dir`, or it reaches `gh auth + status` and passes only on a laptop that happens to be logged in — which + is exactly how the first version of this file went green locally and red + in CI. """ source = Path(__file__).read_text(encoding="utf-8") - early = ("--help", "--wat", '"secret"') + early = ("--help", '"-h"', "--wat", '"secret"') for match in re.finditer(r"(? dict: + """`GIT_ALLOW_PROTOCOL=file`, a fresh `TMPDIR` and a `GIT_TRACE` file.""" + tmpdir = tmp_path / "tmpdir" + tmpdir.mkdir() + env = {"GIT_ALLOW_PROTOCOL": "file", "TMPDIR": str(tmpdir), + "GIT_TRACE": str(tmp_path / "git-trace.log")} + env.update(extra) + return env + + +def clones_and_checkouts(tmp_path: Path) -> tuple: + """Every `git clone` git traced, and every temporary checkout left.""" + trace = tmp_path / "git-trace.log" + lines = (trace.read_text(encoding="utf-8", errors="replace").splitlines() + if trace.exists() else []) + clones = [line for line in lines if "git clone" in line] + left = sorted((tmp_path / "tmpdir").glob("openreposhape-shape-*")) + return clones, left + + +@pytest.mark.parametrize("args", [ + pytest.param(["--help"], id="--help"), + pytest.param(["-h"], id="-h"), + pytest.param(["Sample", "--org", "demoorg", "--help"], + id="after-a-runs-arguments"), + pytest.param(["--keep-shape-checkout", "--shape-ref", "main", "-h"], + id="beside-the-checkout-flags"), +]) +def test_help_from_a_download_is_the_shims_own_and_clones_nothing(tmp_path, + args): + """`-h` and `--help` both, because `setup-project.py` reads both as the + help, and wherever they stand before `--`, because that is where it reads + them too. `$OPENREPOSHAPE_REPO` points at THIS checkout, so a regression + would clone locally and print `setup-project.py`'s usage after a `(0) + self-bootstrap` line — and `--keep-shape-checkout` would leave that clone + in `TMPDIR` for the last assertion to find.""" + outside = make_outside_checkout(tmp_path) + result = run_bare_setup(outside / "setup.sh", *args, cwd=outside, + extra_env=offline_env( + tmp_path, OPENREPOSHAPE_REPO=str(REPO))) + assert result.returncode == 0, result.stderr + result.stdout + assert result.stdout.startswith(SHIM_HELP_FIRST_LINE + "\n"), result.stdout + assert result.stderr == "" + assert "(0) self-bootstrap" not in result.stdout + assert "usage:" not in result.stdout, ( + "a usage was printed, which outside a checkout can only be a clone's") + clones, left = clones_and_checkouts(tmp_path) + assert not clones, f"asking for help cloned: {clones}" + assert not left, f"asking for help left a temporary checkout: {left}" + + +def test_the_shims_help_names_the_clone_and_where_the_flags_are(tmp_path): + """What the text must say, and the one thing it must not: a flag list. + There is still exactly one of those (#50), `setup-project.py --help`, + and the shim's text names it and `docs/cli.md` instead of copying it. + The default it names is the one the clone uses, read from the shim.""" + outside = make_outside_checkout(tmp_path) + result = run_bare_setup(outside / "setup.sh", "--help", cwd=outside, + extra_env=offline_env(tmp_path)) + assert result.returncode == 0, result.stderr + result.stdout + out = result.stdout + assert "$OPENREPOSHAPE_REPO" in out + assert shim_default_repo() in out + assert "python3 setup-project.py --help" in out + assert "docs/cli.md" in out + flags = set(re.findall(r"(? -- --help` is NOT a request for the shim's help: + what follows `--` is `scaffold-project.py`'s. So the run takes the clone + path like any other (of THIS checkout, offline), `setup-project.py` reads + the flag as passthrough, and its plan step — `scaffold-project.py + --help --dry-run` — prints the SCAFFOLD's usage. No `--yes` and no + terminal, so it stops after the plan and nothing is created. + + The traced clone here is also the control for the tests above: it shows + `clones_and_checkouts` does see a clone when there is one.""" + outside = make_outside_checkout(tmp_path) + result = run_bare_setup(outside / "setup.sh", "Sample", "--org", "demoorg", + "--local-remote-dir", str(tmp_path / "remotes"), + "--into", str(tmp_path / "work"), "--", flag, + cwd=outside, + extra_env=offline_env( + tmp_path, OPENREPOSHAPE_REPO=str(REPO))) + assert result.returncode == 2, result.stderr + result.stdout + assert "(0) self-bootstrap" in result.stdout + assert SHIM_HELP_FIRST_LINE not in result.stdout + assert "usage: setup-project.py" not in result.stdout + result.stderr + assert "usage: scaffold-project.py" in result.stdout, result.stdout + assert "no terminal to confirm on" in result.stderr + assert not (tmp_path / "remotes").exists() + clones, left = clones_and_checkouts(tmp_path) + assert clones, "the run never cloned, or GIT_TRACE no longer shows one" + assert not left, f"the temporary checkout was left behind: {left}" + + # --- the shim's own bash-side guards ----------------------------------------- # # These three refuse BEFORE section 0's `mktemp -d` / `git clone` ever runs,