From 968db85289de4b4ff36b3831e1673aabc3ee3539 Mon Sep 17 00:00:00 2001
From: Brett Heap 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.
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, 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.
setup.sh actually runsIt 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"(?