Skip to content
Merged
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,8 @@ the second it refuses, creating nothing, if anything stands at `<into>/Atlas`:
```sh
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
python3 scaffold-project.py --org <your-org> --project Atlas \
--visibility private --elected-by 'Your Name' --dry-run
python3 scaffold-project.py --org <your-org> --project Atlas \
--visibility private --elected-by 'Your Name' # or public / internal
mkdir -p <into>/<Family> # --family only, and skipped when <into> IS <Family>
Expand Down Expand Up @@ -1083,11 +1084,15 @@ file: `apply --at <commit> --yes --add AGENTS-shape.md` copies it, chmods it if
the materializer would, names it in a root `.gitattributes` block of #471,
appends its `sha256` row and moves the pin, all inside the same transaction and
rolled back whole if a validator goes red. Without `--add` it says `not added;
pass --add <path>` and re-pins the rest. `--add` refuses a path that is already
pinned, one the target's lists do not name — and one the root ALREADY has a file
at, because two files with one name is a merge and this command copies bytes. A
project that declines an addition has no way to say so yet: it will be reported
by every `check` until it is taken or a file arrives at that path.
pass --add <path>` and re-pins the rest. `--add` refuses a path already pinned
(`update-add-already-pinned`), one the target's lists do not name
(`update-add-not-upstream`), one with a symlink at or above it, which is never
written through (`update-add-symlink`), one with a file where a directory above
it goes (`update-add-not-a-directory`) — and one the root ALREADY has a file at
(`update-add-would-overwrite`), because two files with one name is a merge and
this command copies bytes. A project that declines an addition has no way to say
so yet: it will be reported by every `check` until it is taken or a file arrives
at that path.

`--upstream` takes a path to a clone (offline, and what the tests use) or an
`owner/repo` it bare-clones itself; it defaults to the repository the pin
Expand Down
29 changes: 17 additions & 12 deletions docs/handbook.html
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@

<!-- reads README.md § preamble: blob ddadd23a162129f7e64fefce899105661440c05c -->

<!-- reads README.md § Starting a project in a new organisation: blob 4e4093125221b1ce5d2369613635d189b461d36f -->
<!-- reads README.md § Starting a project in a new organisation: blob f0693d8ad542c7eba50d9f25babe23675ed05e9d -->

<!-- reads README.md § The three legs: blob 6ebed796b7b135151ff3ca2c5280fb501e001acc -->

Expand Down Expand Up @@ -609,7 +609,7 @@ <h4>The one thing to read before electing it</h4>

<section id="start">

<!-- reads README.md § Starting a project in a new organisation: blob 4e4093125221b1ce5d2369613635d189b461d36f -->
<!-- reads README.md § Starting a project in a new organisation: blob f0693d8ad542c7eba50d9f25babe23675ed05e9d -->

<p class="eyebrow">Setup</p>
<h2>Start here</h2>
Expand Down Expand Up @@ -972,7 +972,8 @@ <h4>What <code>setup.sh</code> actually runs</h4>
it refuses, creating nothing, if anything stands at <code>&lt;into&gt;/Atlas</code>:</p>
<div class="code" data-lang="sh"><pre><code>git clone --depth 1 https://github.com/opensoft/openRepoShape.git &lt;tmp-dir&gt;
python3 scripts/validate-repository-naming.py --explain Atlas Atlas-spec Atlas-code
python3 scaffold-project.py --org &lt;your-org&gt; --project Atlas --dry-run
python3 scaffold-project.py --org &lt;your-org&gt; --project Atlas \
--visibility private --elected-by 'Your Name' --dry-run
python3 scaffold-project.py --org &lt;your-org&gt; --project Atlas \
--visibility private --elected-by 'Your Name' # or public / internal
mkdir -p &lt;into&gt;/&lt;Family&gt; # --family only, and skipped when &lt;into&gt; IS &lt;Family&gt;
Expand Down Expand Up @@ -1021,7 +1022,7 @@ <h4>What you have afterwards &mdash; the same on every platform</h4>

<section id="partway">

<!-- reads README.md § Starting a project in a new organisation: blob 4e4093125221b1ce5d2369613635d189b461d36f -->
<!-- reads README.md § Starting a project in a new organisation: blob f0693d8ad542c7eba50d9f25babe23675ed05e9d -->

<p class="eyebrow">Setup</p>
<h2>If it fails part-way</h2>
Expand Down Expand Up @@ -1056,7 +1057,7 @@ <h2>If it fails part-way</h2>

<section id="ci">

<!-- reads README.md § Starting a project in a new organisation: blob 4e4093125221b1ce5d2369613635d189b461d36f -->
<!-- reads README.md § Starting a project in a new organisation: blob f0693d8ad542c7eba50d9f25babe23675ed05e9d -->

<!-- reads README.md § The three legs: blob 6ebed796b7b135151ff3ca2c5280fb501e001acc -->

Expand Down Expand Up @@ -1114,7 +1115,7 @@ <h2>Reading private legs in CI</h2>

<section id="legs">

<!-- reads README.md § Starting a project in a new organisation: blob 4e4093125221b1ce5d2369613635d189b461d36f -->
<!-- reads README.md § Starting a project in a new organisation: blob f0693d8ad542c7eba50d9f25babe23675ed05e9d -->

<!-- reads README.md § The three legs: blob 6ebed796b7b135151ff3ca2c5280fb501e001acc -->

Expand Down Expand Up @@ -1350,7 +1351,7 @@ <h3>The InkRouter example</h3>

<section id="doublepin">

<!-- reads README.md § Starting a project in a new organisation: blob 4e4093125221b1ce5d2369613635d189b461d36f -->
<!-- reads README.md § Starting a project in a new organisation: blob f0693d8ad542c7eba50d9f25babe23675ed05e9d -->

<!-- reads README.md § The double pin, and the lockstep invariant: blob 57fffa08ffad5476f8eae73dd3e8494a02e050c9 -->

Expand Down Expand Up @@ -1450,7 +1451,7 @@ <h2>Bootstrap is COPIED into the project, not fetched</h2>

<section id="current">

<!-- reads README.md § Keeping a project's shape current: blob 408c49c1603699fbe7ae74fa16c6330ec7860c10 -->
<!-- reads README.md § Keeping a project's shape current: blob 9d21bfcf6a23fc23b4d2dd6a18a4f32f75ee400b -->

<p class="eyebrow">Reference</p>
<h2>Keeping a project&rsquo;s shape current</h2>
Expand Down Expand Up @@ -1493,8 +1494,12 @@ <h2>Keeping a project&rsquo;s shape current</h2>
something to do. <code>apply</code> writes NONE of them unless <code>--add &lt;path&gt;</code>
names one, per file, and names each file it adds in the line-ending block of an adopted
root&rsquo;s own <code>.gitattributes</code> when that file carries #471&rsquo;s block;
<code>--add</code> refuses a path that is already pinned, one the target&rsquo;s lists do
not name, and one the root ALREADY has a file at.</li>
<code>--add</code> refuses a path that is already pinned
(<code>update-add-already-pinned</code>), one the target&rsquo;s lists do not name
(<code>update-add-not-upstream</code>), one with a symlink at or above it, which is never
written through (<code>update-add-symlink</code>), one with a file where a directory above
it goes (<code>update-add-not-a-directory</code>), and one the root ALREADY has a file at
(<code>update-add-would-overwrite</code>).</li>
</ul>
<p><code>--upstream</code> takes a path to a clone (offline) or an <code>owner/repo</code> it
bare-clones itself; it defaults to the repository the pin names. <code>make bootstrap</code>
Expand All @@ -1508,7 +1513,7 @@ <h2>Keeping a project&rsquo;s shape current</h2>

<section id="doctor">

<!-- reads README.md § Keeping a project's shape current: blob 408c49c1603699fbe7ae74fa16c6330ec7860c10 -->
<!-- reads README.md § Keeping a project's shape current: blob 9d21bfcf6a23fc23b4d2dd6a18a4f32f75ee400b -->

<p class="eyebrow">Reference</p>
<h2>Is this repository compliant? <code>openRepoShape --doctor</code></h2>
Expand Down Expand Up @@ -1642,7 +1647,7 @@ <h2>Is this repository compliant? <code>openRepoShape --doctor</code></h2>

<section id="park">

<!-- reads README.md § Starting a project in a new organisation: blob 4e4093125221b1ce5d2369613635d189b461d36f -->
<!-- reads README.md § Starting a project in a new organisation: blob f0693d8ad542c7eba50d9f25babe23675ed05e9d -->

<!-- reads README.md § Carrying in-flight work to another workstation: blob b84512ad39c09085dfe6bf7ea694017f5e28c1dd -->

Expand Down
15 changes: 15 additions & 0 deletions tests/readme-cap.d/550.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
+5
2026-10-14
Five lines for two statements the README had wrong (#550, #429).
One: the by-hand setup commands under "What setup.sh does" dry-ran the scaffold
without the --visibility and --elected-by that setup-project.py's scaffold_args
always passes, so the plan a reader ran could name a different elector than the
real run wrote. The dry run now carries both, which takes a second line, as the
real run's does (+1).
Two: the refusals `update-shape.py apply --add` gives were listed as three, in
prose, and the tool gives five, each with a code of its own. A reader who met
`update-add-symlink` or `update-add-not-a-directory` found no sentence for it,
and the list read as complete. The sentence now names all five with what each
refuses and the code to search for, and it cannot be shorter than that: the five
codes, in backticks and parentheses, are 138 characters on their own, and the
two refusals it lacked add about as many again (+4).
159 changes: 159 additions & 0 deletions tests/test_setup_by_hand_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# SPDX-License-Identifier: Apache-2.0
"""The by-hand scaffold commands the documents print are the ones setup runs (#550).

README.md's "What setup.sh does" and the handbook's "What `setup.sh` actually
runs" list "the commands that flow runs", and a reader who runs them by hand
trusts that they are. Two of them are `scaffold-project.py`: the plan, which
`setup-project.py` runs with `--dry-run`, and the real run. Both get the same
arguments from `scaffold_args`, which ALWAYS passes `--visibility` and
`--elected-by`; the documents showed the plan without them, so the plan a
reader ran could name a different elector than the real run wrote (#550).
Nothing tied the printed commands to the flow.

This is the guard. It does not read the source for the flags: it RUNS
`plan_and_confirm` and `scaffold` in process with `run` replaced by a recorder,
so what it compares is the argument list those two functions hand to the
scaffold, in the order they build it, `--dry-run` last on the plan. The
documents are held to that, after the two things a printed command does that a
list does not: a backslash continues a line, and `# ...` is a comment.

The values are the documents' own: the project `Atlas`, the visibility
`private`, the elector `Your Name`, and the organisation the documents write as
the placeholder `<your-org>`, which is stood in for by `ORG` on both sides. A
flag `setup-project.py` passes only when it is given (`--id`, `--name`,
`--local-remote-dir`) is in neither: the printed lines show the default run, and
so do the recorded ones.

Nothing here starts a process, touches git or GitHub, or reads a terminal, so
it holds on every platform; the printed commands are POSIX and are rendered
with `shlex.quote`, which is what they are written in.
"""

from __future__ import annotations

import html
import re
import shlex
import subprocess
from pathlib import Path

from conftest import REPO

from test_setup_project_py import entry_point_module

#: The organisation the documents write as a placeholder, and what stands in
#: for it on both sides of the comparison.
PLACEHOLDER = "<your-org>"
ORG = "ORG"

#: Where each document's list of commands starts. The README's is the first
#: fenced block under its heading; the handbook's is the first `<pre>` after
#: its subheading.
README_BLOCK = re.compile(
r"^### What setup\.sh does$.*?^```sh\n(.*?)^```$", re.S | re.M)
HANDBOOK_BLOCK = re.compile(
r"What <code>setup\.sh</code> actually runs</h4>.*?"
r"<pre><code>(.*?)</code></pre>", re.S)

SCAFFOLD_COMMAND = "python3 scaffold-project.py"


def scaffold_lines(block: str) -> list[str]:
"""The `scaffold-project.py` commands in a printed block, each as one line
with its continuations joined, its comment dropped and its whitespace
collapsed, and the organisation placeholder replaced by `ORG`."""
lines = []
for line in block.replace("\\\n", " ").splitlines():
line = " ".join(re.sub(r"\s+#.*$", "", line).split())
if line.startswith(SCAFFOLD_COMMAND):
lines.append(line.replace(PLACEHOLDER, ORG))
return lines


def what_setup_runs(monkeypatch, tmp_path: Path) -> list[str]:
"""The two `scaffold-project.py` commands `setup-project.py` runs for the
documents' own values, as `python3 scaffold-project.py <arguments>` lines:
the plan first, then the real run."""
module = entry_point_module()
ran: list[list[str]] = []

def record(argv, *args, **kwargs):
ran.append([str(part) for part in argv])
return subprocess.CompletedProcess(args=argv, returncode=0,
stdout="", stderr="")

monkeypatch.setattr(module, "run", record)
opts = module.Options()
opts.project = "Atlas"
opts.visibility = "private"
opts.elected_by = "Your Name"
opts.assume_yes = True
args = module.scaffold_args(opts, ORG)
module.plan_and_confirm(opts, REPO, ORG, args, str(tmp_path / "Atlas"))
module.scaffold(REPO, args)
assert len(ran) == 2, ran
lines = []
for argv in ran:
assert Path(argv[1]).name == "scaffold-project.py", argv
lines.append(" ".join([SCAFFOLD_COMMAND,
*(shlex.quote(part) for part in argv[2:])]))
return lines


def assert_the_same(name: str, printed: list[str], ran: list[str]) -> None:
"""The document's `scaffold-project.py` lines are the ones setup runs, in
order, and the failure shows both side by side."""
assert printed == ran, (
f"{name} prints these `scaffold-project.py` commands as the ones "
"setup runs (the plan, then the real run):\n "
+ "\n ".join(printed) + "\nsetup-project.py runs these:\n "
+ "\n ".join(ran)
+ "\nThe plan and the real run take the same arguments from "
"`scaffold_args`, `--visibility` and `--elected-by` included (#550).")


def test_the_readme_prints_the_commands_setup_runs(monkeypatch, tmp_path):
text = (REPO / "README.md").read_bytes().decode("utf-8")
found = README_BLOCK.search(text)
assert found, "README.md has no fenced block under `### What setup.sh does`"
assert_the_same(
"README.md", scaffold_lines(found.group(1)),
what_setup_runs(monkeypatch, tmp_path))


def test_the_handbook_prints_the_commands_setup_runs(monkeypatch, tmp_path):
text = (REPO / "docs" / "handbook.html").read_bytes().decode("utf-8")
found = HANDBOOK_BLOCK.search(text)
assert found, ("docs/handbook.html has no code block under `What "
"setup.sh actually runs`")
assert_the_same(
"docs/handbook.html", scaffold_lines(html.unescape(found.group(1))),
what_setup_runs(monkeypatch, tmp_path))


def test_setup_runs_the_plan_and_the_real_run_with_the_same_arguments(
monkeypatch, tmp_path):
plan, real = what_setup_runs(monkeypatch, tmp_path)
assert plan == real + " --dry-run"
assert "--visibility private" in real and "--elected-by 'Your Name'" in real

Check warning on line 138 in tests/test_setup_by_hand_commands.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this composite assertion into separate assertions.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaE8zHmGHC-mBWKZJLU3&open=AaE8zHmGHC-mBWKZJLU3&pullRequest=579


def test_a_printed_command_is_read_as_one_line_without_its_comment():
block = ("git clone x\n"
"python3 scaffold-project.py --org <your-org> --project Atlas \\\n"
" --visibility private --elected-by 'Your Name' # or public\n"
"mkdir -p y # python3 scaffold-project.py is not here\n")
assert scaffold_lines(block) == [
"python3 scaffold-project.py --org ORG --project Atlas "
"--visibility private --elected-by 'Your Name'"]


def test_a_plan_printed_without_the_flags_the_real_run_has_is_a_different_line():
printed = scaffold_lines(
"python3 scaffold-project.py --org <your-org> --project Atlas "
"--dry-run\n")
assert printed == ["python3 scaffold-project.py --org ORG --project Atlas "
"--dry-run"]
assert printed[0] != ("python3 scaffold-project.py --org ORG --project "
"Atlas --visibility private --elected-by 'Your Name'"
" --dry-run")
Loading
Loading