From 968db85289de4b4ff36b3831e1673aabc3ee3539 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 18:06:58 +0000 Subject: [PATCH 1/2] setup.sh: `-h`/`--help` from anywhere but a checkout of the standard is answered by the shim itself, in a few lines that clone nothing and list no flag, and `scripts/render-cli-reference.py` refuses to render or `--check` a tree that is not a checkout and runs every child under `GIT_ALLOW_PROTOCOL=file`, so asking a downloaded `setup.sh` for help no longer clones the upstream, dies with git's exit 128 offline or prints the clone's usage, and the generator in a `git archive` extraction no longer reaches GitHub (#341) Section 0's one scan of the command line now also notes `-h` and `--help` before the first `--`, the two spellings setup-project.py reads as the help, and the shim answers them before the $OPENREPOSHAPE_REPO guard, before `mktemp` and before `git clone`, exactly where setup-project.py answers them: before it looks at anything. The text says what the shim is, that any other run from there clones the standard first (naming $OPENREPOSHAPE_REPO and its default, which is now one DEFAULT_SHAPE_REPO constant the clone reads too, never the variable's value), and that the one flag list is `setup-project.py --help` (offline, from a checkout or from that one file) and docs/cli.md. It lists no flag, so #50's one `--help` holds. `setup.sh -- --help` is still a run: what follows `--` is the scaffold's. Inside a checkout nothing changes: the shim still execs setup-project.py before section 0 reads a word. The generator asks the shim's own question before it renders anything, in `main` and in `render`: `git -C rev-parse --show-toplevel`, both of is_shape_checkout's files at that top level, and one more thing the shim does not ask, that the top level IS the tree, since a copy nested in another checkout would print that checkout's usage. The refusal names the tree and says to run it in a `git clone`. Every child also runs under GIT_ALLOW_PROTOCOL=file, so a regression fails closed. Neither changes a byte of the rendering inside a checkout. The shim's header, README (and the handbook's reading of it, with its blob), `openRepoShape --help`'s one sentence about `./setup.sh --help`, and tests/test_setup_sh.py's gh docstring now say what `--help` does from a download. tests/test_setup_project_py.py's #50 guard now admits the one `-h|--help)` label and still refuses any other. docs/cli.md is regenerated: a note under setup.sh, and that openRepoShape sentence. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- docs/cli.md | 7 +- docs/handbook.html | 17 ++- openRepoShape | 5 +- scripts/render-cli-reference.py | 109 ++++++++++++++- setup.sh | 102 ++++++++++---- tests/test_cli_reference.py | 181 ++++++++++++++++++++++++- tests/test_repo_hygiene.py | 12 +- tests/test_setup_project_py.py | 15 ++- tests/test_setup_sh.py | 227 ++++++++++++++++++++++++++++++-- 10 files changed, 624 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index e8da9381..24c1fd0c 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. `openRepoShape --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: diff --git a/docs/cli.md b/docs/cli.md index a68cf640..6156eae1 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 ceaba2fe..84977516 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.

+ openRepoShape --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.

  • @@ -1786,9 +1789,11 @@

    For AI assistants

    family.yaml beside project.yaml as a manifest kinds finding added (#211); bump-leg.py rewriting tracked workflow files only, and refusing an untracked or ignored one it would rewrite, added (#207); the assistant rules - saying that some scaffold refusals name a value to change, added (#288) — 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 + saying that some scaffold refusals name a value to change, added (#288); what + setup.sh --help does from a download, which clones nothing, added (#341) + — a page cannot name the commit that contains it, and the blob below is the fact a + test checks. + README.md blob 24c1fd0c97d6dc818c1759f2dd4cdb1474e3a48e. 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..bd93a94c 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,76 @@ 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 + toplevel = proc.stdout.decode("utf-8", "replace").strip() + 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 +638,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 +713,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.sh b/setup.sh index 25e93915..ee16ba75 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,19 +121,7 @@ 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. # TWO KNOWN FALSE POSITIVES, both harmless: a VALUE spelled @@ -139,15 +132,80 @@ esac # 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. +# +# `-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..2043c7a8 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,170 @@ 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}") diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 75f93811..5261fe7f 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -1149,8 +1149,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..13ba862c 100644 --- a/tests/test_setup_project_py.py +++ b/tests/test_setup_project_py.py @@ -1570,9 +1570,12 @@ 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. Its one case label is 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 +1635,16 @@ 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") def test_the_shim_names_the_command_line_tools_on_a_mac(): diff --git a/tests/test_setup_sh.py b/tests/test_setup_sh.py index 338807e3..35c40355 100644 --- a/tests/test_setup_sh.py +++ b/tests/test_setup_sh.py @@ -83,10 +83,15 @@ def make_outside_checkout(tmp_path: Path) -> 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,24 @@ 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, and never the + shim's own text. `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 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 +377,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, From d15a888cd45b650d816c6053eaf7c2f0eeb40286 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 18:41:05 +0000 Subject: [PATCH 2/2] setup.sh: the #50 guard holds every `--help` in the shim to its help text or its one `-h|--help)` label, so a quoted second label or a `[[ $1 == --help ]]` reader no longer passes it, `scripts/render-cli-reference.py` accepts a checkout whose path ends in a space or is not UTF-8, and README and the handbook stop saying `openRepoShape --help` lists every flag and that the shim is about seventy lines (#341) review341 found the guard wider than the commit before it said. Adding `--help` to SHIM_FLAGS excused the spelling everywhere in the shim, and only an unquoted `--help)` label was pinned, so a quoted `"--help")`, a `[[ $1 == --help ]]` in the developer path or inside the scan passed the guard and every help test beside it. The spelling is now excused in two places and no third: shim_help()'s lines, each of which must be one `say "..."` with no command substitution, `${...}` or backtick in it, and the one label, outside which neither `--help` nor a bare `-h` may occur in the shim's code. Each of those variants, a `[[ ]]` reader chained or substituted into a say line, and a bare `-h` reader fails it, and it passes on the unmutated shim. The inside-a-checkout test now compares stderr as well as stdout, as its name says: a second reader that speaks on stderr there was invisible to it. The generator read git's top level with `.strip()` and a `replace` decode, so a checkout whose directory name ends in a space, or whose path is not UTF-8, was refused as "not a checkout" while the shim's own `$(...)` kept the bytes and treated it as one. It now reads the path with `os.fsdecode` and drops only the line ending. A test for each runs `--check` in a real `git init` copy of that name and expects exit 0; both are skipped where the file system cannot hold the name. README and the handbook's copy of the sentence now name `python3 setup-project.py --help` as the command that lists every flag (`openRepoShape --help` names eight of its fourteen besides `--help`), and the shim is no longer "about seventy lines of code" there or in setup-project.py's docstring: the count was 93 before #341 and is 122 now, and a count in prose goes stale again, so it is dropped. The handbook footer's history clause and README blob follow. The comment above section 0's scan said the scan never decides what the run does; it now says the third read decides whether there is a run at all. docs/cli.md is unchanged: no `--help` text moved. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++++---- docs/handbook.html | 27 +++++++-------- scripts/render-cli-reference.py | 11 ++++++- setup-project.py | 10 +++--- setup.sh | 13 +++++--- tests/test_cli_reference.py | 58 +++++++++++++++++++++++++++++++++ tests/test_setup_project_py.py | 24 +++++++++++++- tests/test_setup_sh.py | 10 ++++-- 8 files changed, 132 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 24c1fd0c..5465d135 100644 --- a/README.md +++ b/README.md @@ -305,8 +305,8 @@ 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, from a checkout, `setup.sh --help` list -every flag, this one included. From a download, `setup.sh --help` clones +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 @@ -519,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/handbook.html b/docs/handbook.html index 84977516..ba0c325f 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -864,12 +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, 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.

    + 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.

  • @@ -932,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
    @@ -1790,10 +1790,11 @@ 

    For AI assistants

    finding added (#211); bump-leg.py rewriting tracked workflow files only, and refusing an untracked or ignored one it would rewrite, added (#207); the assistant rules saying that some scaffold refusals name a value to change, added (#288); what - setup.sh --help does from a download, which clones nothing, added (#341) + 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 24c1fd0c97d6dc818c1759f2dd4cdb1474e3a48e. Every command, flag, exit code + 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/scripts/render-cli-reference.py b/scripts/render-cli-reference.py index bd93a94c..44772b71 100755 --- a/scripts/render-cli-reference.py +++ b/scripts/render-cli-reference.py @@ -342,7 +342,16 @@ def require_a_checkout(tree: Path = REPO) -> None: 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 - toplevel = proc.stdout.decode("utf-8", "replace").strip() + # 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( 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 ee16ba75..034fdbcb 100755 --- a/setup.sh +++ b/setup.sh @@ -123,15 +123,18 @@ fi # -------------------------------------------------------------------------- # 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 diff --git a/tests/test_cli_reference.py b/tests/test_cli_reference.py index 2043c7a8..45fac457 100644 --- a/tests/test_cli_reference.py +++ b/tests/test_cli_reference.py @@ -577,3 +577,61 @@ def test_the_checkout_refusal_comes_before_the_no_bash_one(tmp_path, 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_setup_project_py.py b/tests/test_setup_project_py.py index 13ba862c..7945b9cd 100644 --- a/tests/test_setup_project_py.py +++ b/tests/test_setup_project_py.py @@ -1573,7 +1573,8 @@ def test_a_rehearsal_that_stops_at_the_plan_makes_no_family_folder(tmp_path): #: 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. Its one case label is pinned below. +#: 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", "--help"} @@ -1645,6 +1646,27 @@ def test_setup_sh_parses_no_flags_of_its_own(): # 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"(?