From 026280cdcb1694d844a731a3769a0adb45b22587 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 23:34:56 +0000 Subject: [PATCH 01/58] openRepoShape: `--install` records the ref it runs at in `openRepoShape.ref` beside the command it places, and the installed command then fetches setup.sh at that ref, hands setup.sh `--shape-ref` for it and checks the doctor's clone out at it, so a command installed from a release stays on that release while `$OPENREPOSHAPE_REF` still overrides it for one run (#564) An installed command read `$OPENREPOSHAPE_REF` at every run and fell back to main, so from v1.0.0 on, with main carrying v1.1.0 work, a command installed from a release drifted off it within hours. The ref is now NAMED by the variable or by the record, and both places that honoured only the exported variable (the doctor's checkout and setup.sh's `--shape-ref`) honour either. The record is a sidecar file, never an edit to the command: the placed command stays byte for byte the file it was installed from, so the installer's own `(unchanged)` verdict and any copy vendored and checked by sha256 still compare bytes and never see the record. An install with no ref records nothing and removes any record already there, which is the command exactly as before. The ref is checked before anything is placed, and the record is checked when it is read, naming the file and the two ways out. `--version` prints the ref in force. docs/cli.md is regenerated for the `--help` paragraph that says so. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 11 +- openRepoShape | 123 +++++++++++++-- tests/test_openreposhape_command.py | 235 +++++++++++++++++++++++++++- 3 files changed, 355 insertions(+), 14 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c793a3e0..7e81e1af 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -150,11 +150,20 @@ opensoft/openRepoTools', and its own installer places them: curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoTools/main/openRepoTools | bash -s -- --install $OPENREPOSHAPE_REPO owner/name to fetch from (default opensoft/openRepoShape) - $OPENREPOSHAPE_REF the ref to fetch it at (default main) + $OPENREPOSHAPE_REF the ref to fetch it at (default: the ref --install + recorded, else main) $OPENREPOSHAPE_ORG the organisation, when --org is not given $OPENREPOSHAPE_BIN_DIR where --install puts it (default ~/.local/bin) $OPENREPOSHAPE_SETUP_SH run this setup.sh on disk instead of fetching one +`--install` RECORDS THE REF it runs at, in `openRepoShape.ref` beside the +command it places: $OPENREPOSHAPE_REF, which the one-line installer +(install.sh) sets to the release it installs. The installed command then +stays on that ref: it fetches setup.sh at it, setup.sh clones the standard +at it (`--shape-ref`), and `--doctor` checks its clone out at it. +$OPENREPOSHAPE_REF set for one run still wins, and `--version` prints the +ref in force. Installed with no ref, it records none and follows main. + `--doctor [] [--json] [--placement-plan ]` runs `shape-doctor.py` over (default `.`) and prints one row per check and ONE verdict: `COMPLIANT`, `COMPLIANT, SHAPE BEHIND`, `DRIFTED`, `MISPLACED`, `INVALID`, diff --git a/openRepoShape b/openRepoShape index 58bbc3f2..246bf217 100755 --- a/openRepoShape +++ b/openRepoShape @@ -3,7 +3,12 @@ # # openRepoShape — the short way in: `openRepoShape Atlas --org `. # -# Install it once, from anywhere, with no checkout and no clone: +# Install it once, from anywhere, with no checkout and no clone. The newest +# release, pinned to it (`install.sh`, #564): +# +# curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash +# +# Or this file at main, by hand, which follows main: # # gh api repos/opensoft/openRepoShape/contents/openRepoShape \ # -H 'Accept: application/vnd.github.raw' | bash -s -- --install @@ -28,9 +33,23 @@ set -euo pipefail # setup.sh reads the same variable as something to `git clone` — see # shape_clone_url below: one name, both uses. REPO="${OPENREPOSHAPE_REPO:-opensoft/openRepoShape}" -REF="${OPENREPOSHAPE_REF:-main}" SELF="${BASH_SOURCE[0]:-$0}" +# THE REF, AND WHETHER IT WAS NAMED (#564). A NAMED ref is one somebody +# chose: setup.sh is told it (`--shape-ref`) and `--doctor` checks its clone +# out at it, where an unnamed `main` leaves both to the default branch. Two +# things name one, and the first wins: `$OPENREPOSHAPE_REF`, set for a run, +# and the ref `--install` RECORDED beside this file (`$SELF.ref`, read by +# `read_recorded_ref` below, once `checked_ref` exists to check it). With +# neither, it is `main`, unnamed, exactly as before there was a record. +REF="${OPENREPOSHAPE_REF:-main}" +REF_NAMED=0 +[ -z "${OPENREPOSHAPE_REF:-}" ] || REF_NAMED=1 + +#: The record's name is the command's own plus this, so the record of +#: `~/.local/bin/openRepoShape` is `~/.local/bin/openRepoShape.ref`. +RECORD_SUFFIX=".ref" + PROJECT="" ORG="" MODE="run" @@ -89,11 +108,20 @@ opensoft/openRepoTools', and its own installer places them: curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoTools/main/openRepoTools | bash -s -- --install $OPENREPOSHAPE_REPO owner/name to fetch from (default opensoft/openRepoShape) - $OPENREPOSHAPE_REF the ref to fetch it at (default main) + $OPENREPOSHAPE_REF the ref to fetch it at (default: the ref --install + recorded, else main) $OPENREPOSHAPE_ORG the organisation, when --org is not given $OPENREPOSHAPE_BIN_DIR where --install puts it (default ~/.local/bin) $OPENREPOSHAPE_SETUP_SH run this setup.sh on disk instead of fetching one +`--install` RECORDS THE REF it runs at, in `openRepoShape.ref` beside the +command it places: $OPENREPOSHAPE_REF, which the one-line installer +(install.sh) sets to the release it installs. The installed command then +stays on that ref: it fetches setup.sh at it, setup.sh clones the standard +at it (`--shape-ref`), and `--doctor` checks its clone out at it. +$OPENREPOSHAPE_REF set for one run still wins, and `--version` prints the +ref in force. Installed with no ref, it records none and follows main. + `--doctor [] [--json] [--placement-plan ]` runs `shape-doctor.py` over (default `.`) and prints one row per check and ONE verdict: `COMPLIANT`, `COMPLIANT, SHAPE BEHIND`, `DRIFTED`, `MISPLACED`, `INVALID`, @@ -288,9 +316,54 @@ machine_report() { say " run: openRepoShape --preflight (offers what is missing; each install asks first)" } +# THE REF THIS INSTALL RAN AT, WRITTEN BESIDE THE COMMAND IT PLACED (#564). +# Without it an installed command read `$OPENREPOSHAPE_REF` at every run and +# fell back to `main`, so a command installed from a release fetched the next +# day's main: the release it was installed from lasted until main moved. +# +# A FILE BESIDE THE COMMAND, NEVER AN EDIT TO IT. The placed command stays +# byte for byte the file it was installed from, so `cmp` against the release's +# own `openRepoShape` still says whether it IS that release: the `(unchanged)` +# verdict `install_commands` gives, and any copy vendored and checked by +# sha256 elsewhere, compare the command's bytes and never see the record. A +# command copied away without its record follows `main` again, and says so in +# `--version`. +# +# THE REF RECORDED IS THE ONE IN FORCE: `$OPENREPOSHAPE_REF`, else the record +# beside the copy being run, so a pinned command re-installing itself stays +# pinned. With neither, any record already there is REMOVED, which is what +# makes an install at main follow main however the command was installed +# before. Every outcome is one line, and no ref and no record is no line at +# all. The record's old value is echoed only when it passes the same shape +# check, because a hand-edited file is not this command's to print. +record_ref() { + local record="$1$RECORD_SUFFIX" was="" + if [ -f "$record" ]; then + was="$(< "$record")" || was="" + case "$was" in + '' | [!A-Za-z0-9]* | *..* | *[!A-Za-z0-9._/-]*) was="another ref" ;; + esac + [ "${#was}" -le 255 ] || was="another ref" + fi + if [ "$REF_NAMED" -eq 0 ]; then + [ -e "$record" ] || return 0 + rm -f -- "$record" + say "openRepoShape: no longer pinned to ${was:-another ref}; it follows main, or \$OPENREPOSHAPE_REF where set (removed $record)" + elif [ "$was" = "$REF" ]; then + say "openRepoShape: already pinned to $REF in $record (unchanged)" + else + printf '%s\n' "$REF" >"$record" + say "openRepoShape: pinned to $REF${was:+ (was $was)}, recorded in $record" + fi +} + install_commands() { local dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" local name target source verb placed=0 + # THE REF IS CHECKED BEFORE ANYTHING IS COLLECTED OR PLACED: it is about + # to be written down and read back by every later run, and a refusal + # after the copy would leave a new command beside an old record. + [ "$REF_NAMED" -eq 0 ] || checked_ref "$REF" collect_commands mkdir -p "$dir" for name in "${INSTALLABLES[@]}"; do @@ -307,6 +380,9 @@ install_commands() { placed=$((placed + 1)) say "$name: $verb" done + # The record belongs to the one command that reads it, and is written + # only once that command is in place. + record_ref "$dir/openRepoShape" # THE COUNT, out loud, and kept at one installable (#92): it costs a line # and is what keeps $INSTALLABLES honest — a person reading N lines cannot # tell whether an N+1th was meant to be there. The sentence that made @@ -354,7 +430,8 @@ find_python() { # A CHECKOUT OF THE STANDARD, made the way `setup.sh` self-bootstraps one: # `git clone` of `shape_clone_url`, into the temporary directory `workdir` -# owns, checked out at $OPENREPOSHAPE_REF when one was named. Sets $SHAPE_DIR. +# owns, checked out at $REF when one was named: by $OPENREPOSHAPE_REF, or by +# the record `--install` left beside this file (#564). Sets $SHAPE_DIR. # # WITHOUT `--depth 1`, WHICH IS THE ONE DEVIATION FROM setup.sh AND IS FORCED. # A shallow clone has exactly ONE commit, and the doctor's shape-currency row @@ -378,7 +455,7 @@ shape_checkout() { die "could not clone $REPO into a temporary directory, so there is no standard to check against. \`--doctor\` needs one: run it from a checkout of openRepoShape, or make the clone work." - if [ -n "${OPENREPOSHAPE_REF:-}" ]; then + if [ "$REF_NAMED" -eq 1 ]; then checked_ref "$REF" # ` --`, never `-- `: a `--` BEFORE the argument makes git # read it as a PATHSPEC and try to restore a file of that name from @@ -397,19 +474,44 @@ shape_checkout() { # CHARACTER next, because a newline in it could forge a line of output before # this script has said a word; and only then the SHAPE, which is safe to echo # back because it has passed both. +# +# WHOSE REF IT IS, AND THE WAY OUT, ARE ARGUMENTS (#564): `$OPENREPOSHAPE_REF` +# by default, or the record `--install` wrote, whose refusal also says how to +# replace it. An EMPTY ref is refused too, which no caller could reach before +# the record: an empty variable is no ref at all, while an empty file is one. checked_ref() { - local ref="$1" + local ref="$1" what="${2:-\$OPENREPOSHAPE_REF}" out="${3:+ $3}" + [ -n "$ref" ] || + die "$what is empty, which is not a branch, tag or commit this tool will pass to \`git checkout\`.$out" if [ "${#ref}" -gt 255 ]; then - die "\$OPENREPOSHAPE_REF is ${#ref} characters long; a branch, tag or commit this tool will pass to \`git checkout\` is at most 255 characters." + die "$what is ${#ref} characters long; a branch, tag or commit this tool will pass to \`git checkout\` is at most 255 characters.$out" fi case "$ref" in - *[![:print:]]*) die "\$OPENREPOSHAPE_REF carries a control character, which is not a branch, tag or commit this tool will pass to \`git checkout\`." ;; + *[![:print:]]*) die "$what carries a control character, which is not a branch, tag or commit this tool will pass to \`git checkout\`.$out" ;; esac case "$ref" in - [!A-Za-z0-9]* | *..* | *.lock | *[!A-Za-z0-9._/-]*) die "\$OPENREPOSHAPE_REF is '$ref', which is not a branch, tag or commit this tool will pass to \`git checkout\`: a ref starts with a letter or digit, uses only letters, digits, '.', '_', '/', '-', has no '..' and does not end in '.lock'." ;; + [!A-Za-z0-9]* | *..* | *.lock | *[!A-Za-z0-9._/-]*) die "$what is '$ref', which is not a branch, tag or commit this tool will pass to \`git checkout\`: a ref starts with a letter or digit, uses only letters, digits, '.', '_', '/', '-', has no '..' and does not end in '.lock'.$out" ;; esac } +# THE RECORD `--install` LEFT BESIDE THIS FILE, READ ONCE, BEFORE ANY RUN +# (#564). Only when no `$OPENREPOSHAPE_REF` was set, which is what lets that +# variable win for a run, and only beside a FILE: a script arriving on stdin +# has no place of its own to have been installed at. CHECKED HERE, AT ONCE, +# rather than where the ref is used: it is a file on disk that every run +# reads, `--version` prints it, and a value nobody typed is not echoed until +# it has passed. `$(< file)` is bash's own read, so no `cat` is needed. +read_recorded_ref() { + local record="$SELF$RECORD_SUFFIX" + [ "$REF_NAMED" -eq 0 ] && [ -f "$SELF" ] && [ -f "$record" ] || return 0 + REF="$(< "$record")" || + die "could not read $record, the ref \`--install\` recorded. Run the installer again, or delete that file to follow main." + checked_ref "$REF" "the ref recorded in $record" \ + "Run the installer again to record a release, or delete that file to follow main." + REF_NAMED=1 +} +read_recorded_ref + # `--doctor`: RUN THE STANDARD'S OWN SCRIPT, never a reimplementation here. # Beside a checkout -- this file sitting in one, which is how a developer runs # it -- that checkout answers, and nothing is fetched at all. Installed, or @@ -671,7 +773,8 @@ ARGS=() [ -z "$PROJECT" ] || ARGS+=(--project "$PROJECT") # Fetched at $REF, so setup.sh self-bootstraps its checkout at $REF too: # otherwise the script that runs and the standard it clones are two commits. -[ -z "${OPENREPOSHAPE_REF:-}" ] || ARGS+=(--shape-ref "$REF") +# Whenever the ref was NAMED, by the variable or by the record (#564). +[ "$REF_NAMED" -eq 0 ] || ARGS+=(--shape-ref "$REF") ARGS+=(${PASSTHROUGH[@]+"${PASSTHROUGH[@]}"}) # $OPENREPOSHAPE_SETUP_SH is the OFFLINE TEST PATH: a setup.sh already on disk diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index d6b94f82..59cb0c00 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -34,7 +34,7 @@ import pytest -from conftest import REPO, WINDOWS_SKIP +from conftest import REPO, WINDOWS_SKIP, git COMMAND = REPO / "openRepoShape" SETUP = REPO / "setup.sh" @@ -113,7 +113,8 @@ def command_env(home: Path | None = None, env: dict | None = None, def run_cmd(*args: str, home: Path | None = None, env: dict | None = None, - setup_sh: Path | None = SETUP) -> subprocess.CompletedProcess: + setup_sh: Path | None = SETUP, + command: Path = COMMAND) -> subprocess.CompletedProcess: """The command, run from its file, with that environment. `input=""` means stdin is a pipe rather than a terminal, which is what the @@ -121,8 +122,11 @@ def run_cmd(*args: str, home: Path | None = None, env: dict | None = None, controlling terminal either (#492): setup asks at that terminal when stdin is not one, and the terminal pytest was started from is nobody's answer. + + `command` is this checkout's file unless a test names an INSTALLED copy, + which is how the record `--install` leaves beside one is read (#564). """ - return subprocess.run(["bash", str(COMMAND), *args], capture_output=True, + return subprocess.run(["bash", str(command), *args], capture_output=True, text=True, check=False, input="", env=command_env(home, env, setup_sh), start_new_session=True) @@ -1002,3 +1006,228 @@ def test_install_from_stdin_fetches_itself_into_a_live_workdir(offline_github, assert stat.S_IMODE(target.stat().st_mode) == 0o755, name assert target.read_bytes() == (REPO / name).read_bytes(), name assert f"{name}: installed at" in result.stdout + + +# --- the ref `--install` records, and the installed command runs (#564) ----- + +#: A ref for an install to record. Nothing fetches it: every install below +#: runs from this checkout's file, which copies its own bytes, and every run +#: after it is offline. `OTHER` is what `$OPENREPOSHAPE_REF` names for one +#: run, over the record. +PINNED = "v9.9.9" +OTHER = "other" + + +def install_at(tmp_path, ref=None, command=COMMAND): + """`--install` from `command` into `/bin`, with + `$OPENREPOSHAPE_REF` set to `ref` when one is given; the result, and the + path of the command it places.""" + bin_dir = tmp_path / "bin" + env = {"OPENREPOSHAPE_BIN_DIR": str(bin_dir)} + if ref is not None: + env["OPENREPOSHAPE_REF"] = ref + return (run_cmd("--install", home=tmp_path, env=env, command=command), + bin_dir / "openRepoShape") + + +def record_of(installed: Path) -> Path: + """Where `--install` records the ref of the command at `installed`.""" + return installed.with_name(installed.name + ".ref") + + +def version_of(installed: Path, env: dict | None = None) -> str: + result = run_cmd("--version", command=installed, env=env) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def test_install_records_the_ref_beside_a_command_it_leaves_unchanged( + tmp_path): + """THE RECORD IS A FILE BESIDE THE COMMAND, AND THE COMMAND IS THE FILE. + + The placed bytes must still be the bytes it was installed from, because + everything that asks whether an installed command IS a release compares + bytes: this installer's own `(unchanged)` verdict, and a copy vendored + elsewhere and checked by sha256 against the release's own file. So the + ref goes in `openRepoShape.ref`, said on its own line between the + command's line and the count, and `--version` then names it with no + variable set, while the variable still wins for the one run it is set + for. + """ + result, installed = install_at(tmp_path, PINNED) + assert result.returncode == 0, result.stderr + record = record_of(installed) + assert record.read_bytes() == f"{PINNED}\n".encode("ascii") + assert installed.read_bytes() == COMMAND.read_bytes(), ( + "the command must stay byte for byte the file it was installed from") + assert result.stdout.splitlines()[:3] == [ + f"openRepoShape: installed at {installed}", + f"openRepoShape: pinned to {PINNED}, recorded in {record}", + f"openRepoShape: 1 of 1 placed in {installed.parent}"] + assert version_of(installed) == \ + f"openRepoShape (opensoft/openRepoShape @ {PINNED})" + assert version_of(installed, {"OPENREPOSHAPE_REF": OTHER}) == \ + f"openRepoShape (opensoft/openRepoShape @ {OTHER})" + + +def test_an_install_with_no_ref_records_nothing_and_follows_main(tmp_path): + """No ref at install time is the install as it always was: the same + lines, no record, and `main`, unnamed, in `--version`.""" + result, installed = install_at(tmp_path) + assert result.returncode == 0, result.stderr + assert not record_of(installed).exists() + assert "pinned" not in result.stdout + assert version_of(installed) == \ + "openRepoShape (opensoft/openRepoShape @ main)" + + +def test_installing_again_keeps_moves_or_drops_the_record(tmp_path): + """Idempotent at the same ref, moved by a new one, dropped by none. + + A second install at the same ref changes neither file and says so twice. + A newer ref replaces the record and names the one it replaced, which is + what an upgrade looks like. The installed command re-installing itself + with no variable set keeps ITS ref, because that is the ref it runs at. + And an install with no ref at all removes the record, so a command put + back at main follows main however it was installed before. + """ + install_at(tmp_path, PINNED) + same, installed = install_at(tmp_path, PINNED) + record = record_of(installed) + assert same.returncode == 0, same.stderr + assert f"openRepoShape: already pinned to {PINNED} in {record} " \ + "(unchanged)" in same.stdout + assert same.stdout.count("(unchanged)") == 2, same.stdout + + newer, _ = install_at(tmp_path, "v10.0.0") + assert f"openRepoShape: pinned to v10.0.0 (was {PINNED}), recorded in " \ + f"{record}" in newer.stdout + itself, _ = install_at(tmp_path, command=installed) + assert "openRepoShape: already pinned to v10.0.0" in itself.stdout + assert record.read_text(encoding="utf-8") == "v10.0.0\n" + + dropped, _ = install_at(tmp_path) + assert dropped.returncode == 0, dropped.stderr + assert "openRepoShape: no longer pinned to v10.0.0; it follows main" \ + in dropped.stdout + assert not record.exists() + assert version_of(installed).endswith("@ main)") + + +def test_a_ref_that_is_not_one_is_refused_before_anything_is_placed( + tmp_path): + """The ref is about to be written down and read by every later run, so + it is checked first, the way the doctor checks it before a checkout.""" + result, installed = install_at(tmp_path, "bad..ref") + assert result.returncode == 2, result.stdout + result.stderr + assert "$OPENREPOSHAPE_REF is 'bad..ref'" in result.stderr + assert not installed.parent.exists(), "a refused install placed something" + + +@pytest.mark.parametrize("text, shown", [ + pytest.param(b"", "is empty", id="empty"), + pytest.param(b"-x\n", "is '-x'", id="an-option"), + pytest.param(b"v1..v2\n", "is 'v1..v2'", id="a-range"), + pytest.param(b"v1\nforged\n", "carries a control character", + id="two-lines"), + pytest.param(b"a" * 256 + b"\n", "is 256 characters long", + id="over-long"), +]) +def test_a_record_that_is_not_a_ref_is_refused_by_name(tmp_path, text, shown): + """A record is a file on disk that every run reads, so it is checked + when it is read, before `--version` can print it, and the refusal names + the file and the two ways out. The control character and the over-long + value are refused without being echoed. `$OPENREPOSHAPE_REF`, set, is + the way past it for one run, because then the record is not read.""" + _result, installed = install_at(tmp_path, PINNED) + record_of(installed).write_bytes(text) + result = run_cmd("--version", command=installed) + assert result.returncode == 2, result.stdout + result.stderr + assert f"the ref recorded in {record_of(installed)} {shown}" \ + in result.stderr + assert "Run the installer again to record a release, or delete that " \ + "file to follow main." in result.stderr + assert "forged" not in result.stderr and "a" * 256 not in result.stderr + assert result.stdout == "" + assert version_of(installed, {"OPENREPOSHAPE_REF": OTHER}).endswith( + f"@ {OTHER})") + + +#: WHICH REF EACH LATER RUN USES: recorded at install, then overridden by the +#: variable for one run, then neither. The third column is what the run must +#: be handed; `None` is `main`, unnamed, which nothing is told. +RECORDED_RUNS = [ + pytest.param(PINNED, None, PINNED, id="the-record"), + pytest.param(PINNED, OTHER, OTHER, id="the-variable-over-the-record"), + pytest.param(None, None, None, id="no-record"), +] + + +@pytest.mark.parametrize("recorded, variable, expected", RECORDED_RUNS) +def test_setup_sh_clones_the_standard_at_the_ref_in_force( + tmp_path, recorded, variable, expected): + """`--shape-ref` reaches setup.sh whenever a ref was NAMED, and the + record names one: the setup.sh this command fetched at that ref then + clones the standard at the same ref, not at main.""" + _result, installed = install_at(tmp_path, recorded) + stub = tmp_path / "setup.sh" + stub.write_text(STUB_SETUP_SH, encoding="utf-8") + result = run_cmd("--preflight", command=installed, setup_sh=stub, + env=None if variable is None + else {"OPENREPOSHAPE_REF": variable}) + assert result.returncode == 0, result.stdout + result.stderr + match = re.search(rf"{STUB_MARKER} in .+ args: (.*)", result.stdout) + assert match, result.stdout + result.stderr + forwarded = match.group(1).split() + if expected is None: + assert "--shape-ref" not in forwarded, forwarded + else: + at = forwarded.index("--shape-ref") + assert forwarded[at + 1] == expected, forwarded + + +def standard_at_three_refs(tmp_path) -> Path: + """A bare repository whose `shape-doctor.py` says which ref it is. + + Three commits: the `PINNED` tag, the `OTHER` branch and `main`, each + with a doctor that prints its own name and nothing else, so a run + reports which ref the command checked its clone out at. + """ + seed = tmp_path / "seed" + seed.mkdir() + git("init", "-q", "-b", "main", cwd=seed) + + def commit(name): + (seed / "shape-doctor.py").write_text( + f"print('STUB DOCTOR AT {name}')\n", encoding="utf-8") + git("add", "-A", cwd=seed) + git("commit", "-q", "-m", name, cwd=seed) + + commit(PINNED) + git("tag", PINNED, cwd=seed) + git("switch", "-q", "-c", OTHER, cwd=seed) + commit(OTHER) + git("switch", "-q", "main", cwd=seed) + commit("main") + bare = tmp_path / "standard.git" + git("clone", "-q", "--bare", str(seed), str(bare), cwd=tmp_path) + return bare + + +@pytest.mark.parametrize("recorded, variable, expected", RECORDED_RUNS) +def test_the_doctor_checks_its_clone_out_at_the_ref_in_force( + tmp_path, recorded, variable, expected): + """The installed `--doctor` clones the standard, and checks the clone + out at the ref in force: the record, the variable over it, or neither, + which leaves the clone on its default branch.""" + _result, installed = install_at(tmp_path, recorded) + temporary = tmp_path / "tmp" + temporary.mkdir() + env = {"OPENREPOSHAPE_REPO": str(standard_at_three_refs(tmp_path)), + "TMPDIR": str(temporary)} + if variable is not None: + env["OPENREPOSHAPE_REF"] = variable + result = run_cmd("--doctor", str(tmp_path), command=installed, env=env) + assert result.returncode == 0, result.stdout + result.stderr + assert f"STUB DOCTOR AT {expected or 'main'}" in result.stdout, ( + result.stdout + result.stderr) From d0cdbf4c7a500fa912d5e2554e799cf8dfeba1e5 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 23:35:06 +0000 Subject: [PATCH 02/58] install.sh: one line, `curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash`, installs the newest release's openRepoShape pinned to that release, installs git-filter-repo with pipx where pipx is and it is not or prints the one command that would, and ends with `openRepoShape --preflight`; README, the handbook and the v1.0.1 release notes lead with it (#564) Brett Heap, 2026-10-14: "we need it to be more simple to install ... now and after, we need a simple install process", and "v1.0.1 soon". install.sh is attached to every release, so `releases/latest/download/` is always the newest. It reads the newest tag from github.com's `releases/latest` redirect with no gh and no login, asking `gh api` only when the redirect names none; takes a named release as its one argument; fetches that release's `openRepoShape`, checks it is the command, and runs its `--install` with `$OPENREPOSHAPE_REF` set to the release, then asks the installed command's `--version` that it stayed there. Every step says what it did in one line. It never runs sudo. Exit 0 installed and ready, 1 installed but the preflight or the pin needs attention, 2 nothing installed; git-filter-repo never moves it. Everything runs from a `main` called on the last line, so a cut-off download runs nothing. README.md: step 3 and the worked example lead with the one line, the gh api and raw-URL lines stay as the by-hand way at main, and Layout gains install.sh; the cap moves 1422 -> 1450, with the reason in test_readme_is_short_enough_to_be_read. docs/handbook.html says the same, names the new README blob and adds the (#564) clause. install.sh joins SHIPPED_BASH and the macOS job's `/bin/bash -n` step, and one test holds the one line byte for byte in every file that carries it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 4 + README.md | 42 ++- docs/handbook.html | 47 ++- docs/release-notes/v1.0.1.md | 65 ++++ install.sh | 361 +++++++++++++++++++++ tests/test_install_sh.py | 596 +++++++++++++++++++++++++++++++++++ tests/test_repo_hygiene.py | 57 +++- 7 files changed, 1143 insertions(+), 29 deletions(-) create mode 100644 docs/release-notes/v1.0.1.md create mode 100755 install.sh create mode 100644 tests/test_install_sh.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e5e47255..08a57826 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -282,11 +282,15 @@ jobs: # for the reason this one exists — those two are what a stock Mac's bash # 3.2 has to parse, and the repository that ships a file is the one that # can say so. + # + # `install.sh` joined the list in v1.0.1 (#564): the one-line install, + # `curl … | bash` on a Mac, hands it to this same `/bin/bash`. - name: /bin/bash parses every bash entry point run: | /bin/bash --version /bin/bash -n setup.sh /bin/bash -n openRepoShape + /bin/bash -n install.sh - name: tests run: python3 -m pytest tests -q diff --git a/README.md b/README.md index f3b378dc..147b3cb5 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,10 @@ is on no `PATH` by default, and its installer finishes by printing the line that puts it there — run that line, or the next shell cannot find `brew`. Then `brew install gh`, and `brew install git-filter-repo` only if you are ADOPTING an existing repository rather than scaffolding a new one. The stock `/bin/bash` is -3.2 and is enough: `setup.sh` and the `openRepoShape` command use no syntax -newer than that, and CI runs the whole suite on `macos-latest`, parsing both -there with `/bin/bash` itself. That the login shell is zsh changes nothing — -both entry points are run under `bash` explicitly. +3.2 and is enough: `setup.sh`, `install.sh` and the `openRepoShape` command use +no syntax newer than that, and CI runs the whole suite on `macos-latest`, +parsing all three there with `/bin/bash` itself. That the login shell is zsh +changes nothing — all three entry points are run under `bash` explicitly. @@ -225,7 +225,27 @@ offer to configure git's credential helper, or run `gh auth setup-git`: the first commits go up with a plain `git push` over HTTPS, and without it the three repositories are created and the push fails. -**3. Install the `openRepoShape` command**, once: +**3. Install the `openRepoShape` command**, once, in one line: + +```sh +curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash +``` + +`install.sh` is attached to every release, so that line installs the newest: +it reads which one that is from github.com's `releases/latest` redirect (no +`gh`, no login), installs that release's command PINNED to it, installs +`git-filter-repo` with `pipx` when `pipx` is there and it is not, or prints +the one command that would, and ends with `openRepoShape --preflight`. It +never runs `sudo`. `| bash -s -- v1.0.1` installs a named release instead. +Exit 0 is installed and ready, 1 installed but the preflight or the pin needs +you, 2 nothing installed. Run the same line again to upgrade. + +**Pinned** means the command stays on its release while main moves on: +`--install` records the ref in `openRepoShape.ref` beside the command, and the +command fetches `setup.sh`, clones the standard and runs `--doctor` at it. +`$OPENREPOSHAPE_REF` overrides it for one run; `--version` prints the ref. + +By hand, the same command at main, which records no ref and follows main: ```sh gh api repos/opensoft/openRepoShape/contents/openRepoShape \ @@ -239,7 +259,7 @@ curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoShape/main/openRep | bash -s -- --install ``` -It installs ONE command into `~/.local/bin`, idempotently — a second run +Each way installs ONE command into `~/.local/bin`, idempotently — a second run prints `unchanged` — and prints the `export PATH=…` line if that directory is not on `PATH`. The estate verbs `park` and `resume`, for [carrying in-flight work](#carrying-in-flight-work-to-another-workstation), are @@ -380,7 +400,14 @@ records as whose act the election was, and it defaults to the `gh` login. #### Install the command -Two ways in, the short one first. Install `openRepoShape` as a command: +Two ways in, the short one first. Install `openRepoShape` as a command, the +newest release and pinned to it, as step 3 above does: + +```sh +curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash +``` + +or by hand, at main, through the API: ```sh gh api repos/opensoft/openRepoShape/contents/openRepoShape \ @@ -1389,6 +1416,7 @@ scripts/path_classify.py the classifier over the path policy scripts/shape_materialize.py the ONE materializer, used by both tools scripts/validate-repository-naming.py openRepoShape installs itself as a command; runs setup.sh +install.sh the one-line install: a release, pinned setup.sh the shim: find an interpreter, hand over setup-project.py THE FLOW, and the Windows way in (no bash there) scaffold-project.py creates the three repositories and the pins diff --git a/docs/handbook.html b/docs/handbook.html index 4e01a52b..377f934a 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -485,11 +485,11 @@

openRepoShape

convention, as data and as running code.

The fastest path — macOS, Linux, WSL2

-
curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoShape/main/openRepoShape \
-    | bash -s -- --install
-openRepoShape --preflight
+          
curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
 openRepoShape <Project> --org <your-org>
-

That first line installs ONE command into ~/.local/bin. The +

That first line installs ONE command into ~/.local/bin: the + newest release, pinned to it, with git-filter-repo where pipx can + install it, and it ends with openRepoShape --preflight. The estate verbs park and resume, for carrying in-flight work to another workstation, are opensoft/openRepoTools’ and have a one-liner of @@ -672,11 +672,11 @@

What this machine needs

brew install gh    # macOS, or Linux with Homebrew

Then brew install git-filter-repo only if you are ADOPTING an existing repository rather than scaffolding a new one.

-

The stock /bin/bash is 3.2 and is enough: setup.sh and the - openRepoShape command use no syntax newer than that, and CI runs the whole - suite on macos-latest, parsing both there with /bin/bash itself. - That the login shell is zsh changes nothing — both entry points are run under - bash explicitly.

+

The stock /bin/bash is 3.2 and is enough: setup.sh, + install.sh and the openRepoShape command use no syntax newer than + that, and CI runs the whole suite on macos-latest, parsing all three there with + /bin/bash itself. That the login shell is zsh changes nothing — all three + entry points are run under bash explicitly.

The tool offers the rest

The preflight names what is missing and, for gh, its login and its credential helper, prints the command for THIS machine and asks once — Type yes to continue: — @@ -755,11 +755,29 @@

Make one machine setting first

Install the openRepoShape command, once

Install the openRepoShape command, once, inside WSL

Fetch the flow, in the directory the new project should land in

+
curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
+

install.sh is attached to every release, so + that line installs the newest: it reads which one that is from github.com’s + releases/latest redirect (no gh, no login), installs that + release’s command PINNED to it, installs git-filter-repo with + pipx when pipx is there and it is not, or prints the one command + that would, and ends with openRepoShape --preflight. It never runs + sudo. | bash -s -- v1.0.1 installs a named release instead. Exit + 0 is installed and ready, 1 installed but the preflight or the pin needs you, 2 nothing + installed. Run the same line again to upgrade.

+

Pinned means the command stays on its + release while main moves on: --install records the ref in + openRepoShape.ref beside the command, and the command fetches + setup.sh, clones the standard and runs --doctor at it. + $OPENREPOSHAPE_REF overrides it for one run; --version prints the + ref.

+

By hand, the same command at main, which records no ref and follows main:

gh api repos/opensoft/openRepoShape/contents/openRepoShape \
     -H 'Accept: application/vnd.github.raw' | bash -s -- --install

Or, where raw downloads are not blocked, the same bytes over HTTPS:

curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoShape/main/openRepoShape \
     | bash -s -- --install
+

By hand, the same command at main, which records no ref and follows main:

curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoShape/main/openRepoShape \
     | bash -s -- --install

Or, where raw downloads are blocked, the same bytes through the API:

@@ -774,13 +792,13 @@

Fetch the flow, in the directory the new project should an exhausted stdin, so it cannot ask for the one yes before three repositories exist — it refuses instead. Downloading the file first leaves stdin attached to the terminal you are typing at.

-

It installs ONE command into ~/.local/bin, +

Each way installs ONE command into ~/.local/bin, idempotently — a second run prints unchanged — and prints the export PATH=… line if that directory is not on PATH. The estate verbs park and resume, for carrying in-flight work, are opensoft/openRepoTools’ and have an install line of their own.

-

It installs ONE command into ~/.local/bin, +

Each way installs ONE command into ~/.local/bin, idempotently — a second run prints unchanged — and prints the export PATH=… line if that directory is not on PATH. It is one file at the repository root and implements nothing: @@ -1750,6 +1768,7 @@

Layout of this repository

scripts/shape_materialize.py
the ONE materializer, used by both tools
scripts/validate-repository-naming.py
answers the naming question for one name
openRepoShape
installs itself as a command; runs setup.sh
+
install.sh
the one-line install: a release, pinned
setup.sh
the shim: find an interpreter, hand over
setup-project.py
THE FLOW, and the Windows way in (no bash there)
scaffold-project.py
creates the three repositories and the pins
@@ -1886,9 +1905,11 @@

For AI assistants

adoption’s --project as the source’s own name, added (#499); the merged-away verdict, whose row apply drops, added (#502); the line-ending block an adoption appends to a root .gitattributes the source keeps, - and its verification as the source’s bytes plus that block, added (#471) — a page + and its verification as the source’s bytes plus that block, added (#471); the one-line + install, install.sh, which installs the newest release pinned to it, and the + command’s openRepoShape.ref record, added (#564) — a page cannot name the commit that contains it, and the blob below is the fact a test checks. - README.md blob f3b378dc5704de79ee63972d0ea0be14dc693209. Every command, flag, exit code + README.md blob 147b3cb5c1d91c96196d6cef09ba984cfa0f6576. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

diff --git a/docs/release-notes/v1.0.1.md b/docs/release-notes/v1.0.1.md new file mode 100644 index 00000000..a5c5e3cb --- /dev/null +++ b/docs/release-notes/v1.0.1.md @@ -0,0 +1,65 @@ +# openRepoShape v1.0.1 + +v1.0.1 is v1.0.0 plus one change: openRepoShape installs in one line, and the +command it installs stays on the release it was installed from (#564). + +## What 1.0.1 adds + +- **The one-line install.** `install.sh` is attached to every release, so + this line always installs the newest one: + + ```sh + curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash + ``` + + It reads which release is the newest from github.com's `releases/latest` + redirect, with no `gh` and no login. It fetches that release's + `openRepoShape` and installs it into `~/.local/bin`, or + `$OPENREPOSHAPE_BIN_DIR`, pinned to the release. It installs + `git-filter-repo` 2.47.0 with `pipx` when `pipx` is there and + `git-filter-repo` is not, and otherwise prints the one command that + installs it. It ends with `openRepoShape --preflight`. It never runs `sudo` + and installs nothing else. It exits 0 when the command is installed and the + preflight passed, 1 when the command is installed but the preflight did not + pass or the command does not stay on the release, and 2 when it installed + nothing. +- **An installed command that stays on its release.** `openRepoShape + --install` records the ref it runs at, `$OPENREPOSHAPE_REF`, in + `openRepoShape.ref` beside the command it places. The installed command then + fetches `setup.sh` at that ref, `setup.sh` clones the standard at it + (`--shape-ref`), and `--doctor` checks its clone out at it. In v1.0.0 an + installed command fetched main on every run unless `$OPENREPOSHAPE_REF` was + exported. `$OPENREPOSHAPE_REF` set for one run still overrides the record, + and `openRepoShape --version` prints the ref in force. An install with no + ref records nothing, removes any record already there, and follows main. The + installed command is byte for byte the release's own file. + +## Install and upgrade + +- **The newest release:** the line above. Run it again to upgrade to the + newest release. +- **A named release:** + `curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash -s -- v1.0.1`, + or `bash install.sh v1.0.1` from a checkout or a download. +- **What is installed:** `openRepoShape --version` prints + `openRepoShape (opensoft/openRepoShape @ v1.0.1)`. +- **By hand, at main:** the README's + `gh api repos/opensoft/openRepoShape/contents/openRepoShape … | bash -s -- --install` + line still installs main's command, which records no ref and follows main. +- **Windows:** `install.sh` is bash, for macOS, Linux and WSL2. Native + Windows still starts with `py setup-project.py`, as the README says. +- **A project's pin** on the standard works as before. Follow + [How to pin 1.0.0](v1.0.0.md#how-to-pin-100) with the v1.0.1 release + commit. + +## A script that runs `--install` + +A script that runs `openRepoShape --install` with `$OPENREPOSHAPE_REF` +exported now pins the installed command to that value. If the value is not a +ref the command should run, unset it for the install. To keep the command on a +commit or a tag, set it to that commit or tag. + +## Known limits + +v1.0.1 changes nothing else, so its known limits are v1.0.0's: +[Known limits in v1.0.0](v1.0.0.md#known-limits). diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..8713a67a --- /dev/null +++ b/install.sh @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# install.sh — openRepoShape in one line, pinned to a release (#564): +# +# curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash +# +# A named release instead of the newest: `… | bash -s -- v1.0.1`. From a +# checkout or a download, the same: `bash install.sh []`. Running the +# same line again upgrades to the newest release. This file is attached to +# every GitHub release, which is what makes that URL always the newest one. +# +# FOUR STEPS, IN THIS ORDER, EACH SAYING IN A LINE WHAT IT DID: +# +# 1. THE RELEASE: the one named, or the newest, read from the redirect +# github.com answers `releases/latest` with. That needs no `gh`, no token +# and no login, because the repository is public; `gh api` is asked only +# when the redirect names no release and `gh` is installed. +# 2. THAT RELEASE'S `openRepoShape`, fetched into a temporary directory, +# checked to be the command, and installed by its own `--install` with +# `$OPENREPOSHAPE_REF` set to the release. `--install` records the ref +# beside the command it places, and the command then stays on it. +# 3. GIT-FILTER-REPO, which `adopt-project.py` needs and nothing else does: +# said when it is there; installed with `pipx` when pipx is there and it +# is not; otherwise the one command that installs it, printed. +# 4. THE INSTALLED COMMAND'S `--preflight`, LAST, and its verdict. +# +# IT NEVER RUNS sudo AND INSTALLS NOTHING ELSE. The command and, through pipx, +# git-filter-repo are the whole of what it installs. The preflight it ends +# with offers what is missing behind a typed `yes`, at the person's own +# terminal, exactly as `openRepoShape --preflight` always has. +# +# EXIT STATUS: +# 0 installed, pinned to the release, and the preflight passed; +# 1 installed, but the preflight did not pass, or the installed command +# does not stay on the release (one older than v1.0.1 cannot record +# it): the lines above say which; +# 2 refused before anything was installed: a version that is not a ref, no +# release found, a fetch that failed, or a file that is not the command. +# git-filter-repo never changes it: only adopting needs it, and +# `adopt-project.py` refuses with its own install command where it is missing. +# +# EVERYTHING RUNS FROM `main`, CALLED ON THE LAST LINE. Under `curl … | bash` +# bash reads this script from the pipe as it goes, so a download cut off +# part-way would run whatever had arrived. Inside a function nothing runs +# until the last line has arrived, and by then the pipe holds nothing a child +# could read in its place. +# +# BASH 3.2, which is /bin/bash on every Mac: no `${x,,}`, no `declare -A`, +# no `mapfile`; an empty array never meets `set -u`. + +set -euo pipefail + +REPO="${OPENREPOSHAPE_REPO:-opensoft/openRepoShape}" + +#: The version CI installs (`.github/workflows/tests.yml`), so what this +#: installs is what the adoption suites ran against. +FILTER_REPO_PIN="git-filter-repo==2.47.0" + +WORKDIR="" + +#: What `latest_from_redirect` and `latest_from_gh` found, and when they did +#: not, why. Globals rather than output, because a function run inside +#: `$(…)` is a subshell and could say only one of the two. +FOUND="" +FAULT="" + +say() { printf '%s\n' "$*"; } +die() { printf '\nREFUSED: %s\n' "$1" >&2; exit "${2:-2}"; } + +usage() { + cat <<'USAGE' +install.sh: install openRepoShape, pinned to a release. + + curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash + curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash -s -- v1.0.1 + bash install.sh [] + +With no it installs the newest release. It fetches that release's +`openRepoShape`, installs it into ~/.local/bin pinned to the release, installs +git-filter-repo with pipx when pipx is there and git-filter-repo is not (or +prints the command that installs it), and ends with `openRepoShape +--preflight`. It never runs sudo. Run the same line again to upgrade. + +Exit 0: installed, pinned, and the preflight passed. 1: installed, but the +preflight did not pass, or the command does not stay on the release. 2: +refused, and nothing was installed. + + $OPENREPOSHAPE_REPO owner/name to install from (default opensoft/openRepoShape) + $OPENREPOSHAPE_BIN_DIR where the command goes (default ~/.local/bin) +USAGE +} + +# THE REPOSITORY, AS AN OWNER/NAME, because every URL below is built from it. +# The launcher also takes a URL or a path in this variable, for the clone +# setup.sh makes; there is no release at either, so this refuses them by +# name. A control character is refused before anything is echoed. +checked_repo() { + case "$REPO" in + *[![:print:]]*) die "\$OPENREPOSHAPE_REPO carries a control character; it must be an owner/name on github.com, such as opensoft/openRepoShape." ;; + esac + case "$REPO" in + [A-Za-z0-9]*/[A-Za-z0-9._-]*) ;; + *) die "\$OPENREPOSHAPE_REPO is '$REPO', which is not an owner/name on github.com, such as opensoft/openRepoShape: this installer builds github.com URLs from it." ;; + esac + case "$REPO" in + */*/* | *[!A-Za-z0-9._/-]* | */. | */..) die "\$OPENREPOSHAPE_REPO is '$REPO', which is not an owner/name on github.com, such as opensoft/openRepoShape: this installer builds github.com URLs from it." ;; + esac +} + +# THE LAUNCHER'S `checked_ref`, TRANSCRIBED: the version reaches a URL here +# and `git checkout` once the command runs, so it is held to the same three +# checks in the same order (length, then a control character, then the +# shape, which is the only one that echoes it), plus the empty value. Not +# shared, because this file arrives alone down a pipe. +checked_version() { + local ref="$1" what="$2" + [ -n "$ref" ] || die "$what is empty. Name a release, such as v1.0.1, or none for the newest." + if [ "${#ref}" -gt 255 ]; then + die "$what is ${#ref} characters long; a release tag, branch or commit is at most 255 characters." + fi + case "$ref" in + *[![:print:]]*) die "$what carries a control character, which is not a release tag, branch or commit." ;; + esac + case "$ref" in + [!A-Za-z0-9]* | *..* | *.lock | *[!A-Za-z0-9._/-]*) die "$what is '$ref', which is not a release tag, branch or commit: a ref starts with a letter or digit, uses only letters, digits, '.', '_', '/', '-', has no '..' and does not end in '.lock'." ;; + esac +} + +# THE NEWEST RELEASE, FROM THE REDIRECT. `releases/latest` answers 302 with +# `location: …/releases/tag/`; a repository with no release yet answers +# with `…/releases`, which names no tag and is no answer. The header name is +# matched in any case (HTTP/2 sends it in lower case, HTTP/1.1 need not), +# without `${x,,}`, which bash 3.2 does not have. +latest_from_redirect() { + local url="$1" headers line value status=0 + FOUND="" + command -v curl >/dev/null 2>&1 || { + FAULT="curl is not installed" + return 1 + } + headers="$(curl -fsSI "$url" 2>/dev/null)" || status=$? + if [ "$status" -ne 0 ]; then + FAULT="curl failed, exit $status" + return 1 + fi + while IFS= read -r line; do + line="${line%$'\r'}" + case "$line" in + [Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:*) + value="${line#*:}" + while :; do + case "$value" in + ' '* | $'\t'*) value="${value#?}" ;; + *) break ;; + esac + done + case "$value" in + */releases/tag/?*) + FOUND="${value##*/releases/tag/}" + return 0 + ;; + esac + case "$value" in + *[![:print:]]*) FAULT="it redirects to a location that names no release" ;; + *) FAULT="it redirects to $value, which names no release" ;; + esac + return 1 + ;; + esac + done <<<"$headers" + FAULT="it answered with no location" + return 1 +} + +# THE FALLBACK, AND ONLY WHERE `gh` IS INSTALLED: `gh api` needs a login even +# for a public repository, which is why it is not the first way. +latest_from_gh() { + local status=0 + FOUND="" + command -v gh >/dev/null 2>&1 || { + FAULT="gh is not installed" + return 1 + } + FOUND="$(gh api "repos/$REPO/releases/latest" --jq .tag_name 2>/dev/null)" || status=$? + if [ "$status" -ne 0 ] || [ -z "$FOUND" ]; then + FAULT="gh failed, exit $status" + return 1 + fi +} + +# THE COMMAND, AND NOT A PAGE THAT SAYS 200. A captive portal, a proxy's +# error page or a renamed file is refused here, before anything runs it: +# the launcher's first line, and the `INSTALLABLES=(` line that +# `tests/test_repo_hygiene.py` holds it to carrying. +looks_like_the_launcher() { + local line first=1 + while IFS= read -r line || [ -n "$line" ]; do + if [ "$first" -eq 1 ]; then + [ "$line" = "#!/usr/bin/env bash" ] || return 1 + first=0 + fi + case "$line" in "INSTALLABLES=("*) return 0 ;; esac + done <"$1" + return 1 +} + +# THE ONE COMMAND THAT INSTALLS IT HERE, for a machine with no pipx. POSIX +# shell, no sudo: Homebrew's where `brew` is, and otherwise pip, under +# whichever Python name this machine has, at the version CI runs. +filter_repo_command() { + local candidate + if command -v brew >/dev/null 2>&1; then + printf '%s' "brew install git-filter-repo" + return 0 + fi + for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1; then + printf '%s' "$candidate -m pip install --user $FILTER_REPO_PIN" + return 0 + fi + done + printf '%s' "python3 -m pip install --user $FILTER_REPO_PIN" +} + +filter_repo_step() { + local found status=0 + if found="$(command -v git-filter-repo 2>/dev/null)" && [ -n "$found" ]; then + say "git-filter-repo: already installed ($found)" + return 0 + fi + if command -v git >/dev/null 2>&1 && + git filter-repo --version >/dev/null 2>&1; then + say "git-filter-repo: already installed (git filter-repo --version answers)" + return 0 + fi + if command -v pipx >/dev/null 2>&1; then + say "git-filter-repo: not installed; installing it with pipx, because adopt-project.py needs it: pipx install $FILTER_REPO_PIN" + pipx install "$FILTER_REPO_PIN" || status=$? + if [ "$status" -ne 0 ]; then + say "git-filter-repo: pipx could not install it (exit $status). Before adopting a repository, install it: pipx install $FILTER_REPO_PIN" + elif command -v git-filter-repo >/dev/null 2>&1; then + say "git-filter-repo: installed with pipx" + else + say "git-filter-repo: installed with pipx, into a directory that is not on PATH yet: run \`pipx ensurepath\` and open a new shell" + fi + return 0 + fi + say "git-filter-repo: not installed, and there is no pipx to install it with. Only adopt-project.py needs it; before adopting a repository, run:" + say " $(filter_repo_command)" +} + +main() { + local version="" asked launcher_url bin_dir target shown status=0 pinned=1 + + # NOTHING A CHILD READS IS THIS SCRIPT. Piped, stdin is the pipe the + # script came down; a terminal stays a terminal, which is where the + # preflight asks before it offers anything. + [ -t 0 ] || exec /dev/null 2>&1 || + die "curl is not installed, and this installer fetches with it. Install curl, or install by hand with: gh api repos/$REPO/contents/openRepoShape -H 'Accept: application/vnd.github.raw' | bash -s -- --install" + + # 1. THE RELEASE. + if [ -n "$version" ]; then + checked_version "$version" "the version" + say "release: $version, as named" + else + asked="https://github.com/$REPO/releases/latest" + if latest_from_redirect "$asked"; then + version="$FOUND" + else + local redirect_fault="$FAULT" + latest_from_gh || die "could not find the newest release of $REPO, so nothing was installed. Both ways failed: + curl -fsSI $asked ($redirect_fault) + gh api repos/$REPO/releases/latest ($FAULT) + Name a release instead, as bash install.sh v1.0.1 or as: + curl -fsSL https://github.com/$REPO/releases/latest/download/install.sh | bash -s -- v1.0.1" + version="$FOUND" + asked="gh api repos/$REPO/releases/latest" + fi + checked_version "$version" "the newest release's tag" + say "release: $version, the newest release of $REPO ($asked)" + fi + + # 2. THAT RELEASE'S COMMAND, FETCHED, CHECKED AND INSTALLED BY ITSELF. + WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/openRepoShape-install.XXXXXXXX")" || + die "could not make a temporary directory under ${TMPDIR:-/tmp}. Set \$TMPDIR to a directory you can write to." + trap 'rm -rf -- "$WORKDIR"' EXIT + launcher_url="https://raw.githubusercontent.com/$REPO/$version/openRepoShape" + curl -fsSL "$launcher_url" -o "$WORKDIR/openRepoShape" || status=$? + [ "$status" -eq 0 ] && [ -s "$WORKDIR/openRepoShape" ] || + die "could not fetch $launcher_url (curl exit $status), so nothing was installed. Is $version a release of $REPO? https://github.com/$REPO/releases" + looks_like_the_launcher "$WORKDIR/openRepoShape" || + die "$launcher_url is not the openRepoShape command (its first line is not #!/usr/bin/env bash, or it has no INSTALLABLES line), so nothing was installed." + say "fetch: $launcher_url" + + bin_dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" + target="$bin_dir/openRepoShape" + say "install: OPENREPOSHAPE_REF=$version openRepoShape --install" + OPENREPOSHAPE_REF="$version" "${BASH:-bash}" "$WORKDIR/openRepoShape" --install || + die "the $version openRepoShape's --install refused (exit $?); its own refusal above says why." + + # THE PIN, CHECKED BY ASKING THE INSTALLED COMMAND, with the variable + # unset: a release older than v1.0.1 installs without recording a ref and + # would follow main, which is the drift this installer exists to end. + shown="$(unset OPENREPOSHAPE_REF; "${BASH:-bash}" "$target" --version 2>/dev/null)" || shown="" + case "$shown" in + *"@ $version)") ;; + *) + pinned=0 + say "install: $target does not stay on $version: it says '${shown:-nothing}' to --version. A release older than v1.0.1 cannot record the ref it was installed at; export OPENREPOSHAPE_REF=$version to keep it there." + ;; + esac + if [ -n "${OPENREPOSHAPE_REF:-}" ] && [ "$OPENREPOSHAPE_REF" != "$version" ]; then + say "install: \$OPENREPOSHAPE_REF is set in this shell, and while it is, every run uses it instead of $version. Unset it to run $version." + fi + + # 3. GIT-FILTER-REPO. + filter_repo_step + + # 4. THE PREFLIGHT, LAST, THROUGH THE INSTALLED COMMAND AND ITS RECORD. + say "preflight: $target --preflight" + status=0 + ( + unset OPENREPOSHAPE_REF + "${BASH:-bash}" "$target" --preflight + ) || status=$? + + if [ "$status" -ne 0 ]; then + say "done: openRepoShape $version is installed at $target, but the preflight did not pass (exit $status); it names what is missing above." + exit 1 + fi + if [ "$pinned" -eq 0 ]; then + say "done: openRepoShape $version is installed at $target and this machine is ready, but the command does not stay on $version (see above)." + exit 1 + fi + say "done: openRepoShape $version is installed at $target, and this machine is ready. Run the same line again to upgrade to the newest release." + exit 0 +} + +# `${1+"$@"}`, setup.sh's own spelling: no arguments is the one-line run, and +# that has to survive `set -u` under every bash this has to run on. +main ${1+"$@"} diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py new file mode 100644 index 00000000..7ced9afc --- /dev/null +++ b/tests/test_install_sh.py @@ -0,0 +1,596 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`install.sh`: openRepoShape in one line, pinned to a release (#564). + +OFFLINE, ON A `$PATH` THE TEST OWNS ENTIRELY. Each run's `$PATH` holds the +real programs the installer and the command it installs need (`bash`, +`mktemp`, `cp` and the rest of `INSTALL_NEEDS`), and FAKES for everything +that would reach the network or install something: `curl`, `gh`, `pipx`, +and, where a test asks for them, `git-filter-repo`, `git` and `brew`. +Restricted rather than prepended, because the machine running this may have +git-filter-repo and pipx installed for real, and which of the three +git-filter-repo branches runs must be the test's choice, not the machine's. + +EVERY FAKE IS ONE PROGRAM. A two-line shell launcher per name hands its +arguments to `FAKE` below, which appends the call to `calls.log` and answers +from `net.json`: the redirect github.com answers `releases/latest` with, the +files raw.githubusercontent.com serves, the tag `gh api` reports, and where +`pipx` puts what it installs. The `setup.sh` it serves is another launcher +into the same program, so the preflight the installer ends with is LOGGED, +with the arguments the installed command handed it: `--shape-ref ` +is where the pin shows. + +THE COMMAND SERVED IS THIS CHECKOUT'S `openRepoShape`, so the install the +installer runs is the real one. Every run sets `GH_TOKEN=bogus` and an empty +`GH_CONFIG_DIR`, and its `TMPDIR` is a directory each test checks is empty +again afterwards. + +WINDOWS_SKIP, for the reason `tests/test_openreposhape_command.py` gives: the +installer and the command are bash, run where `bash` is a bash, and on +Windows the way in is `setup-project.py`. +""" + +from __future__ import annotations + +import json +import re +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP +from test_openreposhape_command import COMMAND, INSTALL_NEEDS, command_env + +INSTALLER = REPO / "install.sh" + +#: Bash by ABSOLUTE path: `subprocess` resolves a bare argv[0] against the +#: child's `$PATH`, and the one each run builds holds only what it chose. +BASH = shutil.which("bash") + +pytestmark = [pytest.mark.skipif(BASH is None, reason="install.sh is bash"), + WINDOWS_SKIP] + +SLUG = "opensoft/openRepoShape" +RELEASE = "v9.9.9" +FILTER_REPO = "git-filter-repo==2.47.0" + + +def latest(slug: str = SLUG) -> str: + return f"https://github.com/{slug}/releases/latest" + + +def tag_page(tag: str, slug: str = SLUG) -> str: + return f"https://github.com/{slug}/releases/tag/{tag}" + + +def raw(name: str, ref: str = RELEASE, slug: str = SLUG) -> str: + return f"https://raw.githubusercontent.com/{slug}/{ref}/{name}" + + +#: THE ONE PROGRAM BEHIND EVERY FAKE. `curl -fsSI` answers a configured +#: redirect with the headers github.com sends, CRLF and all, and `curl +#: -fsSL` a configured file, to `-o` or to stdout; anything else is curl's +#: own exit 22. `gh` reports a release only when one is configured. `pipx` +#: places a `git-filter-repo` launcher where it is told to, or fails. Every +#: call is logged first, so what was NOT asked is as plain as what was. +FAKE = r''' +import json +import shlex +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +NET = json.loads((HERE / "net.json").read_text(encoding="utf-8")) +NAME, ARGS = sys.argv[1], sys.argv[2:] +with open(HERE / "calls.log", "a", encoding="utf-8") as log: + log.write(" ".join([NAME] + ARGS) + "\n") + + +def refuse(message): + sys.stderr.write(message + "\n") + return 22 if NAME == "curl" else 1 + + +def launcher(directory, name): + path = Path(directory) / name + path.write_text("#!/bin/sh\nexec %s %s %s \"$@\"\n" % ( + shlex.quote(sys.executable), shlex.quote(str(Path(__file__))), name), + encoding="utf-8") + path.chmod(0o755) + + +def curl(): + url = next((arg for arg in ARGS if arg.startswith("https://")), "") + if "-fsSI" in ARGS: + location = NET["redirects"].get(url) + if location is None: + return refuse("curl: (22) The requested URL returned error: 404") + sys.stdout.write("HTTP/2 302 \r\nlocation: %s\r\n" + "content-length: 0\r\n\r\n" % location) + return 0 + served = NET["files"].get(url) + if served is None: + return refuse("curl: (22) The requested URL returned error: 404") + data = Path(served).read_bytes() + if "-o" in ARGS: + Path(ARGS[ARGS.index("-o") + 1]).write_bytes(data) + else: + sys.stdout.buffer.write(data) + return 0 + + +def gh(): + if ARGS[:1] == ["api"] and ARGS[1].endswith("/releases/latest") \ + and NET.get("gh_latest"): + print(NET["gh_latest"]) + return 0 + return refuse("fake gh: no answer for: " + " ".join(ARGS)) + + +def pipx(): + if NET.get("pipx_installs_into") is None: + return refuse("fake pipx: could not install " + " ".join(ARGS)) + launcher(NET["pipx_installs_into"], "git-filter-repo") + return 0 + + +def git(): + if ARGS == ["filter-repo", "--version"] and NET.get("git_filter_repo"): + print("a40bce548d2c") + return 0 + return refuse("fake git: no answer for: " + " ".join(ARGS)) + + +def setup_sh(): + print("fake preflight: " + " ".join(ARGS)) + return NET.get("preflight_exit", 0) + + +sys.exit({"curl": curl, "gh": gh, "pipx": pipx, "git": git, + "setup.sh": setup_sh}.get(NAME, lambda: 0)()) +''' + +#: A RELEASE OLDER THAN v1.0.1, as far as the installer can tell: the +#: launcher's first line and its `INSTALLABLES` line, an `--install` that +#: copies itself and records nothing, and a `--version` that therefore says +#: `main`. It is all the installer asks of a command. +OLD_LAUNCHER = """#!/usr/bin/env bash +INSTALLABLES=(openRepoShape) +case "$1" in +--install) mkdir -p "$OPENREPOSHAPE_BIN_DIR" && cp "$0" "$OPENREPOSHAPE_BIN_DIR/openRepoShape" ;; +--version) printf 'openRepoShape (%s @ %s)\\n' "$OPENREPOSHAPE_REPO" "${OPENREPOSHAPE_REF:-main}" ;; +esac +""" + + +class Net: + """The fakes, what they answer, the `$PATH` that holds them, and a run. + + `fakes` names the programs on that `$PATH` that answer through `FAKE`; + `serve` adds a release's two files; `config` is written to `net.json` + at every `run`, so a test changes an answer between two runs by + changing it here. + """ + + def __init__(self, tmp_path: Path, fakes=("curl", "gh", "pipx")): + self.tmp_path = tmp_path + self.root = tmp_path / "net" + self.root.mkdir() + (self.root / "fake.py").write_text(FAKE, encoding="utf-8") + self.path = tmp_path / "path" + self.path.mkdir() + for name in (*INSTALL_NEEDS, "bash"): + real = shutil.which(name) + assert real, f"this machine has no {name}, which the install needs" + (self.path / name).symlink_to(real) + for name in fakes: + self.fake(name) + self.temporary = tmp_path / "tmp" + self.temporary.mkdir() + self.bin = tmp_path / ".local" / "bin" + self.config = {"redirects": {latest(): tag_page(RELEASE)}, + "files": {}, "pipx_installs_into": str(self.path)} + self.serve(RELEASE) + + def fake(self, name: str, directory: Path | None = None) -> Path: + """A launcher for `name` into `FAKE`, on this `$PATH` by default.""" + path = (directory or self.path) / name + path.write_text( + f"#!/bin/sh\nexec {shlex.quote(sys.executable)} " + f"{shlex.quote(str(self.root / 'fake.py'))} {name} \"$@\"\n", + encoding="utf-8") + path.chmod(0o755) + return path + + def serve(self, ref: str, slug: str = SLUG, + launcher: Path = COMMAND) -> None: + """`openRepoShape` and `setup.sh` at `ref`, as raw URLs answer.""" + served = self.root / "served" / ref + served.mkdir(parents=True, exist_ok=True) + self.config["files"][raw("openRepoShape", ref, slug)] = str(launcher) + self.config["files"][raw("setup.sh", ref, slug)] = str( + self.fake("setup.sh", served)) + + def run(self, *args: str, piped: bool = False, + env: dict | None = None) -> subprocess.CompletedProcess: + """`bash install.sh `, or `… | bash -s -- ` when piped.""" + (self.root / "net.json").write_text(json.dumps(self.config), + encoding="utf-8") + gh_config = self.tmp_path / "gh-config" + gh_config.mkdir(exist_ok=True) + argv = [BASH, "-s", "--", *args] if piped \ + else [BASH, str(INSTALLER), *args] + return subprocess.run( + argv, capture_output=True, text=True, check=False, + input=INSTALLER.read_text(encoding="utf-8") if piped else "", + cwd=str(self.tmp_path), start_new_session=True, + env=command_env(home=self.tmp_path, setup_sh=None, env={ + "PATH": str(self.path), "GH_TOKEN": "bogus", + "GH_CONFIG_DIR": str(gh_config), + "TMPDIR": str(self.temporary), **(env or {})})) + + def calls(self) -> list: + log = self.root / "calls.log" + return log.read_text(encoding="utf-8").splitlines() \ + if log.exists() else [] + + def installed(self, result, ref: str = RELEASE) -> None: + """The command is placed, byte for byte, with `ref` recorded beside + it, and the run left nothing in `$TMPDIR`.""" + target = self.bin / "openRepoShape" + assert target.read_bytes() == COMMAND.read_bytes(), ( + result.stdout + result.stderr) + assert (self.bin / "openRepoShape.ref").read_text( + encoding="utf-8") == f"{ref}\n" + assert list(self.temporary.iterdir()) == [] + + def refused(self, result) -> None: + """Exit 2, nothing placed, no preflight run, nothing left behind.""" + assert result.returncode == 2, result.stdout + result.stderr + assert not self.bin.exists(), "a refused install placed something" + assert not any(call.startswith("setup.sh") for call in self.calls()) + assert list(self.temporary.iterdir()) == [] + + +@pytest.fixture +def net(tmp_path): + return Net(tmp_path) + + +def preflight_call(ref: str = RELEASE) -> str: + """How the preflight reaches setup.sh through a command pinned to `ref`.""" + return f"setup.sh --shape-ref {ref} --preflight" + + +# --- the one line ------------------------------------------------------------ + +def test_the_one_line_installs_the_newest_release_pinned_to_it(net): + """`curl … | bash`: the newest release, from the redirect alone. + + One line per step, in order, and the calls behind them in the same + order: the redirect, the release's command, then the preflight, LAST, + through the installed command, whose `setup.sh` is fetched at the + release and handed `--shape-ref` for it. `gh` is never asked for a + release when the redirect named one. + """ + result = net.run(piped=True) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + target = net.bin / "openRepoShape" + steps = [ + f"release: {RELEASE}, the newest release of {SLUG} ({latest()})", + f"fetch: {raw('openRepoShape')}", + f"install: OPENREPOSHAPE_REF={RELEASE} openRepoShape --install", + f"openRepoShape: pinned to {RELEASE}, recorded in {target}.ref", + "git-filter-repo: ", + f"preflight: {target} --preflight", + f"done: openRepoShape {RELEASE} is installed at {target}, and this " + "machine is ready. Run the same line again to upgrade to the newest " + "release.", + ] + lines = result.stdout.splitlines() + at = [next(i for i, line in enumerate(lines) if line.startswith(step)) + for step in steps] + assert at == sorted(at), result.stdout + calls = net.calls() + assert calls[0] == f"curl -fsSI {latest()}" + assert calls[1].startswith(f"curl -fsSL {raw('openRepoShape')} -o ") + assert f"curl -fsSL {raw('setup.sh')}" in calls + assert calls[-1] == preflight_call(), calls + assert not any("releases/latest" in call for call in calls[1:]), calls + + +@pytest.mark.parametrize("piped", [False, True], ids=["file", "piped"]) +def test_a_named_release_is_installed_and_no_newest_is_asked_for(net, piped): + """`bash install.sh v9.9.8` and `… | bash -s -- v9.9.8`: that release, + and no question about which one is newest.""" + net.serve("v9.9.8") + result = net.run("v9.9.8", piped=piped) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result, "v9.9.8") + assert "release: v9.9.8, as named" in result.stdout.splitlines() + assert not any("-fsSI" in call for call in net.calls()), net.calls() + assert net.calls()[-1] == preflight_call("v9.9.8") + + +def test_gh_answers_when_the_redirect_names_no_release(net): + """A redirect to `/releases` names no tag (what github.com answers before + a repository's first release); `gh api` is asked next, where installed.""" + net.config["redirects"][latest()] = f"https://github.com/{SLUG}/releases" + net.config["gh_latest"] = RELEASE + result = net.run() + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert f"release: {RELEASE}, the newest release of {SLUG} (gh api " \ + f"repos/{SLUG}/releases/latest)" in result.stdout + + +@pytest.mark.parametrize("location, fakes, redirect_fault, gh_fault", [ + pytest.param(f"https://github.com/{SLUG}/releases", ("curl",), + f"it redirects to https://github.com/{SLUG}/releases, which " + "names no release", "gh is not installed", id="no-release"), + pytest.param(None, ("curl", "gh"), "curl failed, exit 22", + "gh failed, exit 1", id="both-failed"), +]) +def test_no_release_found_names_each_way_that_failed( + tmp_path, location, fakes, redirect_fault, gh_fault): + """Each way names its URL or command and why it gave no answer, and the + way out is to name a release; nothing is fetched or installed.""" + net = Net(tmp_path, fakes) + if location is None: + del net.config["redirects"][latest()] + else: + net.config["redirects"][latest()] = location + result = net.run() + net.refused(result) + assert f"could not find the newest release of {SLUG}" in result.stderr + assert f"curl -fsSI {latest()} ({redirect_fault})" in result.stderr + assert f"gh api repos/{SLUG}/releases/latest ({gh_fault})" \ + in result.stderr + assert "bash install.sh v1.0.1" in result.stderr + assert not any("-fsSL" in call for call in net.calls()) + + +@pytest.mark.parametrize("args, said", [ + pytest.param(("v1..v2",), "the version is 'v1..v2'", id="a-range"), + pytest.param(("x.lock",), "the version is 'x.lock'", id="a-lock-file"), + pytest.param(("/etc",), "the version is '/etc'", id="a-path"), + pytest.param(("v1\nforged",), "the version carries a control character", + id="two-lines"), + pytest.param(("a" * 256,), "the version is 256 characters long", + id="over-long"), + pytest.param(("-x",), "unknown option '-x'", id="an-option"), + pytest.param(("-x\nforged",), "an option carries a control character", + id="an-option-of-two-lines"), + pytest.param(("v1.0.1", "v1.0.2"), "two versions given", id="two"), +]) +def test_a_version_that_is_not_a_ref_is_refused_before_any_request( + net, args, said): + """Checked the way the launcher checks a ref, before any URL is built + from it; a control character and the over-long value never echoed.""" + result = net.run(*args) + net.refused(result) + assert said in result.stderr + assert net.calls() == [] + assert "forged" not in result.stderr and "a" * 256 not in result.stderr + + +def test_a_failed_fetch_names_its_url_and_installs_nothing(net): + del net.config["files"][raw("openRepoShape")] + result = net.run() + net.refused(result) + assert f"could not fetch {raw('openRepoShape')} (curl exit 22), so " \ + "nothing was installed" in result.stderr + + +def test_a_page_that_is_not_the_command_is_refused(net): + """A proxy's or a portal's page can arrive with a 200; it is refused + before anything runs it.""" + page = net.root / "page.html" + page.write_text("Sign in\n", + encoding="utf-8") + net.config["files"][raw("openRepoShape")] = str(page) + result = net.run() + net.refused(result) + assert f"{raw('openRepoShape')} is not the openRepoShape command" \ + in result.stderr + + +# --- git-filter-repo --------------------------------------------------------- + +def present_on_path(net): + net.fake("git-filter-repo") + return f"git-filter-repo: already installed ({net.path}/git-filter-repo)" + + +def answering_git(net): + net.fake("git") + net.config["git_filter_repo"] = True + return "git-filter-repo: already installed (git filter-repo --version " \ + "answers)" + + +def pipx_onto_path(net): + return "git-filter-repo: installed with pipx" + + +def pipx_off_path(net): + elsewhere = net.tmp_path / "pipx-bin" + elsewhere.mkdir() + net.config["pipx_installs_into"] = str(elsewhere) + return "git-filter-repo: installed with pipx, into a directory that is " \ + "not on PATH yet: run `pipx ensurepath` and open a new shell" + + +def pipx_failing(net): + net.config["pipx_installs_into"] = None + return f"git-filter-repo: pipx could not install it (exit 1). Before " \ + f"adopting a repository, install it: pipx install {FILTER_REPO}" + + +def no_pipx(net): + (net.path / "pipx").unlink() + return f" python3 -m pip install --user {FILTER_REPO}" + + +def no_pipx_but_brew(net): + no_pipx(net) + net.fake("brew") + return " brew install git-filter-repo" + + +@pytest.mark.parametrize("arrange", [ + present_on_path, answering_git, pipx_onto_path, pipx_off_path, + pipx_failing, no_pipx, no_pipx_but_brew]) +def test_git_filter_repo_is_said_installed_or_printed(net, arrange): + """The three branches, and the ways each can end. + + PRESENT, on `$PATH` or as `git filter-repo`: said, and pipx is never + run. NOT PRESENT AND PIPX IS THERE: said first, then `pipx install` at + CI's version, then what came of it. NEITHER: the one command for this + machine, printed and never run. None changes the exit status, and the + step sits between the install and the preflight. + """ + expected = arrange(net) + result = net.run() + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + lines = result.stdout.splitlines() + assert expected in lines, result.stdout + ran_pipx = [call for call in net.calls() if call.startswith("pipx")] + if arrange in (pipx_onto_path, pipx_off_path, pipx_failing): + assert ran_pipx == [f"pipx install {FILTER_REPO}"], net.calls() + assert "git-filter-repo: not installed; installing it with pipx, "\ + "because adopt-project.py needs it: pipx install " \ + f"{FILTER_REPO}" in lines + assert net.calls().index(ran_pipx[0]) < \ + net.calls().index(preflight_call()) + else: + assert ran_pipx == [], net.calls() + if arrange in (no_pipx, no_pipx_but_brew): + assert lines[lines.index(expected) - 1] == ( + "git-filter-repo: not installed, and there is no pipx to install " + "it with. Only adopt-project.py needs it; before adopting a " + "repository, run:") + assert lines.index(expected) < lines.index( + f"preflight: {net.bin}/openRepoShape --preflight") + + +# --- the preflight, the exit status, and running it again -------------------- + +def test_a_preflight_that_does_not_pass_exits_one_with_the_command_placed( + net): + net.config["preflight_exit"] = 1 + result = net.run() + assert result.returncode == 1, result.stdout + result.stderr + net.installed(result) + assert f"done: openRepoShape {RELEASE} is installed at " \ + f"{net.bin}/openRepoShape, but the preflight did not pass (exit 1); " \ + "it names what is missing above." in result.stdout + + +def test_a_variable_in_the_shell_is_named_and_the_preflight_runs_the_pin(net): + """`$OPENREPOSHAPE_REF` exported in the person's shell overrides the pin + on every run while it stays set, so the installer says so; the release + is still what is recorded, and the preflight still runs through it.""" + result = net.run(env={"OPENREPOSHAPE_REF": "v0.0.1"}) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert f"install: $OPENREPOSHAPE_REF is set in this shell, and while it " \ + f"is, every run uses it instead of {RELEASE}. Unset it to run " \ + f"{RELEASE}." in result.stdout + assert net.calls()[-1] == preflight_call() + + +def test_the_same_line_again_changes_nothing_and_then_upgrades(net): + """Idempotent while the newest release is the same one; the next + release, once it is the newest, replaces the record and says which it + replaced.""" + assert net.run().returncode == 0 + again = net.run() + assert again.returncode == 0, again.stdout + again.stderr + assert f"openRepoShape: already installed at {net.bin}/openRepoShape " \ + "(unchanged)" in again.stdout + assert f"openRepoShape: already pinned to {RELEASE} in " \ + f"{net.bin}/openRepoShape.ref (unchanged)" in again.stdout + + net.config["redirects"][latest()] = tag_page("v10.0.0") + net.serve("v10.0.0") + upgrade = net.run() + assert upgrade.returncode == 0, upgrade.stdout + upgrade.stderr + net.installed(upgrade, "v10.0.0") + assert f"openRepoShape: pinned to v10.0.0 (was {RELEASE}), recorded in " \ + f"{net.bin}/openRepoShape.ref" in upgrade.stdout + + +def test_a_release_whose_command_records_no_ref_exits_one(net): + """A release before v1.0.1 installs a command that follows main. The + installer asks the installed command, says so, and exits 1.""" + old = net.root / "old-openRepoShape" + old.write_text(OLD_LAUNCHER, encoding="utf-8") + net.serve(RELEASE, launcher=old) + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": str(net.bin), + "OPENREPOSHAPE_REPO": SLUG}) + assert result.returncode == 1, result.stdout + result.stderr + assert f"install: {net.bin}/openRepoShape does not stay on {RELEASE}: " \ + f"it says 'openRepoShape ({SLUG} @ main)' to --version." \ + in result.stdout + assert f"export OPENREPOSHAPE_REF={RELEASE}" in result.stdout + assert result.stdout.splitlines()[-1].endswith( + f"but the command does not stay on {RELEASE} (see above).") + + +# --- the repository it installs from ---------------------------------------- + +def test_openreposhape_repo_names_where_everything_comes_from(net): + """A fork named once is where the release, the command and the + preflight's setup.sh all come from, and the installed command says so.""" + fork = "acme/openRepoShape" + net.config["redirects"] = {latest(fork): tag_page(RELEASE, fork)} + net.config["files"] = {} + net.serve(RELEASE, fork) + result = net.run(env={"OPENREPOSHAPE_REPO": fork}) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert all(SLUG not in call for call in net.calls()), net.calls() + assert f"curl -fsSL {raw('setup.sh', RELEASE, fork)}" in net.calls() + + +@pytest.mark.parametrize("value", [ + "https://github.com/acme/openRepoShape.git", "acme", "acme/x/y", + "/srv/openRepoShape"]) +def test_a_repository_that_is_not_an_owner_and_name_is_refused(net, value): + result = net.run(env={"OPENREPOSHAPE_REPO": value}) + net.refused(result) + assert f"$OPENREPOSHAPE_REPO is '{value}', which is not an owner/name " \ + "on github.com" in result.stderr + assert net.calls() == [] + + +# --- what the file says about itself ------------------------------------------ + +def test_help_prints_the_one_line_and_exits_zero(tmp_path): + result = subprocess.run( + [BASH, str(INSTALLER), "--help"], capture_output=True, text=True, + check=False, input="", + env=command_env(home=tmp_path, setup_sh=None, + env={"GH_TOKEN": "bogus", + "GH_CONFIG_DIR": str(tmp_path)})) + assert result.returncode == 0, result.stderr + assert "curl -fsSL https://github.com/opensoft/openRepoShape/releases/" \ + "latest/download/install.sh | bash" in result.stdout + + +def test_it_installs_the_git_filter_repo_ci_runs(): + """What the installer puts on a machine is what the adoption suites ran + against: the pin it names is every pin `.github/workflows/tests.yml` + installs.""" + [pinned] = re.findall(r'^FILTER_REPO_PIN="(git-filter-repo==[^"]+)"$', + INSTALLER.read_text(encoding="utf-8"), re.M) + ci = re.findall(r"git-filter-repo==[0-9.]+", ( + REPO / ".github" / "workflows" / "tests.yml").read_text( + encoding="utf-8")) + assert ci and set(ci) == {pinned}, (pinned, ci) diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 3761eb76..ff065cf7 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -66,12 +66,13 @@ "shape_advisory", "conftest", "bootstrap"} #: The bash scripts a person runs BEFORE they have a checkout, or with no -#: checkout in sight at all: the front door itself and the command that -#: fetches it. `park` and `resume` were here from #82 until the carve (#92) -#: and are opensoft/openRepoTools' now, held to these same rules by that -#: repository's own copy of this test. Every one is a shipped executable and -#: is held to the same shebang, mode bit and `set -euo pipefail` rule. -SHIPPED_BASH = ["setup.sh", "openRepoShape"] +#: checkout in sight at all: the front door itself, the command that +#: fetches it, and the one-line installer that places that command pinned to +#: a release (#564). `park` and `resume` were here from #82 until the carve +#: (#92) and are opensoft/openRepoTools' now, held to these same rules by +#: that repository's own copy of this test. Every one is a shipped executable +#: and is held to the same shebang, mode bit and `set -euo pipefail` rule. +SHIPPED_BASH = ["setup.sh", "openRepoShape", "install.sh"] #: The entry point a person runs BEFORE they have a checkout on a machine with #: no bash: the same front door, on an interpreter alone. Held to the same @@ -1183,8 +1184,25 @@ def test_readme_is_short_enough_to_be_read(): # 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") + # 2026-10-14: 1422 -> 1450 — the one-line install, v1.0.1 (#564). Brett + # Heap: "we need it to be more simple to install ... now and after, we + # need a simple install process". Step 3 of the quick start now LEADS + # with `curl -fsSL …/releases/latest/download/install.sh | bash`, and + # the worked example's "Install the command" with the same line: + # eleven lines for the two blocks and their lead-ins. Nine say what + # that line does that the old two did not, because each is a thing a + # reader would otherwise meet unannounced on their own machine: it + # reads the newest release from a redirect with no `gh`, it installs + # git-filter-repo with pipx or prints the command, it ends in the + # preflight, it never runs `sudo`, how to name a release, and its three + # exit statuses. Five say what PINNED means — the record beside the + # command, what reads it, and the variable that overrides it — because + # a command that quietly stops following main is a surprise to anyone + # who expected it to. Two re-introduce the existing lines as the + # by-hand way, at main, so neither is lost, and one is the Layout row + # for `install.sh`. + assert len(lines) <= 1450, ( + f"README.md is {len(lines)} lines; the cap is 1450") #: #476: what each document that carries the first rule says an assistant does @@ -1247,7 +1265,7 @@ def test_shipped_bash_is_executable_and_fails_loudly(name): @WINDOWS_SKIP @pytest.mark.parametrize("name", SHIPPED_BASH) def test_shipped_bash_parses_under_bash(name): - """`bash -n` on the two shipped bash scripts, everywhere there is a bash. + """`bash -n` on the shipped bash scripts, everywhere there is a bash. SKIPPED ON WINDOWS, AND `shutil.which` IS NOT ENOUGH TO SEE WHY. The `bash` a stock Windows install puts on PATH is @@ -1341,6 +1359,27 @@ def test_the_openrepotools_install_line_is_the_same_everywhere(name): f" {OPENREPOTOOLS_INSTALL}") +#: THE ONE LINE THAT INSTALLS THIS STANDARD (#564), and every file that +#: carries it: the installer it fetches, the command it installs (in its +#: header), the README and its designed reading, and the release notes that +#: introduced it. Pasted by people who cannot check it, like the two lines +#: above, so the copies are held byte for byte. `releases/latest/download/` +#: is what makes it the NEWEST release's `install.sh` every time. +ONE_LINE_INSTALL = ( + "curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/" + "download/install.sh | bash") +ONE_LINE_INSTALL_IN = ["install.sh", "openRepoShape", "README.md", + "docs/handbook.html", "docs/release-notes/v1.0.1.md"] + + +@pytest.mark.parametrize("name", ONE_LINE_INSTALL_IN) +def test_the_one_line_install_is_the_same_everywhere(name): + text = (REPO / name).read_text(encoding="utf-8") + assert ONE_LINE_INSTALL in text, ( + f"{name} must carry the one-line install byte for byte:\n" + f" {ONE_LINE_INSTALL}") + + def test_nothing_here_installs_park_or_resume(): """The carve, asserted rather than remembered (#92). From 319b005da6d315451a8ef4e31e6f28cfed699186 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:21:46 +0000 Subject: [PATCH 03/58] openRepoShape and install.sh, the fix round on #577: `--install` records a ref only with `OPENREPOSHAPE_RECORD_REF=1`, which install.sh sets, so a script that exports `$OPENREPOSHAPE_REF` around it gets v1.0.0's install; every curl that runs is HTTPS only, redirects included, at TLS 1.2 or newer; a link to the command reads its record; `--help` answers past a broken record; and install.sh says which failure it met (#564) The review on #577 (RV577, FIX-ROUND) and SonarCloud shell:S6506. openRepoShape: - The ref is recorded only when asked, or when it came from the command's own record (a re-install of itself stays pinned). workBenches exports `OPENREPOSHAPE_REF=pinned-by-workBenches-no-fetch` around `--install`; that no longer becomes a record every later run would fail on. Any other install records nothing and removes a record already there. - The raw-URL fallback curl carries `--proto '=https' --proto-redir '=https' --tlsv1.2`, written on the line. - The record is found beside the FILE, symlinks followed with a portable `readlink` loop (no `-f`, at most 40 links). - A broken record is kept as a fault and refused after the arguments are read, so `--help` still answers; `--version` and every run refuse. A record that is a directory is refused in the usual form, by a run and by an install, before anything is placed. install.sh: - Both curls HTTPS only, redirects included, TLS 1.2 or newer. - Sets `OPENREPOSHAPE_RECORD_REF=1` for the install. - A fetch the server refused (curl exit 22) asks whether the release exists; no answer at all points at the network instead. A preflight that exits 1 names what is missing; any other status says it did not run to the end. - With no pipx, `apt-get` machines are told `sudo apt-get install -y pipx && pipx install git-filter-repo==2.47.0` (printed, never run), because Debian 12 and Ubuntu 23.04+ refuse `pip install --user`. - A command that did not record the tag gets its preflight run with the tag named, not main's setup.sh; the shell-profile notes say so. - The last line is `{ main ${1+"$@"}; }`: a download cut inside it runs nothing. install.sh itself never runs sudo; the wording now says the preflight's offers, some of them sudo, each wait for a typed yes. Tests: HTTPS_ONLY in the call asserts and a guard on every curl that runs in both scripts; the opt-in, links, a directory record and `--help` past a bad record; an upper-case and a relative `location`; a non-default OPENREPOSHAPE_BIN_DIR; the apt-get branch; the network failure; the preflight's two failures; three truncated downloads; S9073 composite asserts split. docs/cli.md regenerated for the `--help` change. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 19 +-- install.sh | 130 ++++++++++++------ openRepoShape | 198 ++++++++++++++++++++-------- tests/test_install_sh.py | 188 +++++++++++++++++++++----- tests/test_openreposhape_command.py | 80 ++++++++++- 5 files changed, 474 insertions(+), 141 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 7e81e1af..9f2a8f82 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -155,14 +155,17 @@ opensoft/openRepoTools', and its own installer places them: $OPENREPOSHAPE_ORG the organisation, when --org is not given $OPENREPOSHAPE_BIN_DIR where --install puts it (default ~/.local/bin) $OPENREPOSHAPE_SETUP_SH run this setup.sh on disk instead of fetching one - -`--install` RECORDS THE REF it runs at, in `openRepoShape.ref` beside the -command it places: $OPENREPOSHAPE_REF, which the one-line installer -(install.sh) sets to the release it installs. The installed command then -stays on that ref: it fetches setup.sh at it, setup.sh clones the standard -at it (`--shape-ref`), and `--doctor` checks its clone out at it. -$OPENREPOSHAPE_REF set for one run still wins, and `--version` prints the -ref in force. Installed with no ref, it records none and follows main. + $OPENREPOSHAPE_RECORD_REF 1: --install records $OPENREPOSHAPE_REF (below) + +`--install` RECORDS A REF ONLY WHEN ASKED: with $OPENREPOSHAPE_RECORD_REF=1 +it writes $OPENREPOSHAPE_REF to `openRepoShape.ref` beside the command it +places, which is what the one-line installer (install.sh) does with the +release tag it installs. The installed command then stays on that ref: it +fetches setup.sh at it, setup.sh clones the standard at it (`--shape-ref`), +and `--doctor` checks its clone out at it. $OPENREPOSHAPE_REF set for one run +still wins, and `--version` prints the ref in force. A command re-installing +itself keeps its own record; any other install records nothing, removes a +record already there, and follows main. `--doctor [] [--json] [--placement-plan ]` runs `shape-doctor.py` over (default `.`) and prints one row per check and ONE verdict: diff --git a/install.sh b/install.sh index 8713a67a..453507bb 100755 --- a/install.sh +++ b/install.sh @@ -18,33 +18,36 @@ # when the redirect names no release and `gh` is installed. # 2. THAT RELEASE'S `openRepoShape`, fetched into a temporary directory, # checked to be the command, and installed by its own `--install` with -# `$OPENREPOSHAPE_REF` set to the release. `--install` records the ref -# beside the command it places, and the command then stays on it. +# `$OPENREPOSHAPE_REF` set to the release tag and +# `$OPENREPOSHAPE_RECORD_REF=1`, which asks it to record that tag beside +# the command it places; the command then stays on it. # 3. GIT-FILTER-REPO, which `adopt-project.py` needs and nothing else does: # said when it is there; installed with `pipx` when pipx is there and it # is not; otherwise the one command that installs it, printed. # 4. THE INSTALLED COMMAND'S `--preflight`, LAST, and its verdict. # -# IT NEVER RUNS sudo AND INSTALLS NOTHING ELSE. The command and, through pipx, -# git-filter-repo are the whole of what it installs. The preflight it ends -# with offers what is missing behind a typed `yes`, at the person's own -# terminal, exactly as `openRepoShape --preflight` always has. +# install.sh ITSELF NEVER RUNS sudo, AND INSTALLS NOTHING BUT THESE TWO: the +# command and, through pipx, git-filter-repo. The preflight it ends with +# offers what is missing, each behind a typed `yes` at the person's own +# terminal, exactly as `openRepoShape --preflight` always has, and some of +# those offers are `sudo` commands: that is the preflight's, asked first. # # EXIT STATUS: -# 0 installed, pinned to the release, and the preflight passed; +# 0 installed, pinned to the release tag, and the preflight passed; # 1 installed, but the preflight did not pass, or the installed command -# does not stay on the release (one older than v1.0.1 cannot record +# does not stay on the release tag (one older than v1.0.1 cannot record # it): the lines above say which; # 2 refused before anything was installed: a version that is not a ref, no # release found, a fetch that failed, or a file that is not the command. # git-filter-repo never changes it: only adopting needs it, and # `adopt-project.py` refuses with its own install command where it is missing. # -# EVERYTHING RUNS FROM `main`, CALLED ON THE LAST LINE. Under `curl … | bash` -# bash reads this script from the pipe as it goes, so a download cut off -# part-way would run whatever had arrived. Inside a function nothing runs -# until the last line has arrived, and by then the pipe holds nothing a child -# could read in its place. +# EVERYTHING RUNS FROM `main`, CALLED ON THE LAST LINE, INSIDE BRACES. Under +# `curl … | bash` bash reads this script from the pipe as it goes, so a +# download cut off part-way would run whatever had arrived. Inside a function +# nothing runs until the last line has arrived; the braces make that line one +# command that bash parses whole, so a download cut inside it runs nothing +# either; and by then the pipe holds nothing a child could read in its place. # # BASH 3.2, which is /bin/bash on every Mac: no `${x,,}`, no `declare -A`, # no `mapfile`; an empty array never meets `set -u`. @@ -77,13 +80,15 @@ install.sh: install openRepoShape, pinned to a release. bash install.sh [] With no it installs the newest release. It fetches that release's -`openRepoShape`, installs it into ~/.local/bin pinned to the release, installs -git-filter-repo with pipx when pipx is there and git-filter-repo is not (or -prints the command that installs it), and ends with `openRepoShape ---preflight`. It never runs sudo. Run the same line again to upgrade. +`openRepoShape`, installs it into ~/.local/bin pinned to the release tag, +installs git-filter-repo with pipx when pipx is there and git-filter-repo is +not (or prints the command that installs it), and ends with `openRepoShape +--preflight`. install.sh itself never runs sudo; the preflight it ends with +offers what is missing, each behind a typed `yes` at your terminal. Run the +same line again to upgrade. Exit 0: installed, pinned, and the preflight passed. 1: installed, but the -preflight did not pass, or the command does not stay on the release. 2: +preflight did not pass, or the command does not stay on the release tag. 2: refused, and nothing was installed. $OPENREPOSHAPE_REPO owner/name to install from (default opensoft/openRepoShape) @@ -131,7 +136,13 @@ checked_version() { # `location: …/releases/tag/`; a repository with no release yet answers # with `…/releases`, which names no tag and is no answer. The header name is # matched in any case (HTTP/2 sends it in lower case, HTTP/1.1 need not), -# without `${x,,}`, which bash 3.2 does not have. +# without `${x,,}`, which bash 3.2 does not have, and a relative location is +# read the same way as a whole URL. +# +# EVERY curl HERE IS HTTPS ONLY, REDIRECTS INCLUDED, AT TLS 1.2 OR NEWER +# (SonarCloud shell:S6506), with the flags written out on the curl line +# itself, never gathered into an array, so a reader and the analyser both +# see them there. `tests/test_install_sh.py` holds every curl line to it. latest_from_redirect() { local url="$1" headers line value status=0 FOUND="" @@ -139,7 +150,7 @@ latest_from_redirect() { FAULT="curl is not installed" return 1 } - headers="$(curl -fsSI "$url" 2>/dev/null)" || status=$? + headers="$(curl -fsSI --proto '=https' --proto-redir '=https' --tlsv1.2 "$url" 2>/dev/null)" || status=$? if [ "$status" -ne 0 ]; then FAULT="curl failed, exit $status" return 1 @@ -205,15 +216,22 @@ looks_like_the_launcher() { return 1 } -# THE ONE COMMAND THAT INSTALLS IT HERE, for a machine with no pipx. POSIX -# shell, no sudo: Homebrew's where `brew` is, and otherwise pip, under -# whichever Python name this machine has, at the version CI runs. +# THE ONE COMMAND THAT INSTALLS IT HERE, for a machine with no pipx, PRINTED +# AND NEVER RUN, in POSIX shell: Homebrew's where `brew` is; where `apt-get` +# is, pipx from apt and then git-filter-repo through it, because Debian 12 +# and Ubuntu 23.04 on refuse `pip install --user` into the system Python +# (externally-managed-environment); and otherwise pip, under whichever Python +# name this machine has. Always at the version CI runs, except brew's. filter_repo_command() { local candidate if command -v brew >/dev/null 2>&1; then printf '%s' "brew install git-filter-repo" return 0 fi + if command -v apt-get >/dev/null 2>&1; then + printf '%s' "sudo apt-get install -y pipx && pipx install $FILTER_REPO_PIN" + return 0 + fi for candidate in python3 python; do if command -v "$candidate" >/dev/null 2>&1; then printf '%s' "$candidate -m pip install --user $FILTER_REPO_PIN" @@ -305,17 +323,27 @@ main() { die "could not make a temporary directory under ${TMPDIR:-/tmp}. Set \$TMPDIR to a directory you can write to." trap 'rm -rf -- "$WORKDIR"' EXIT launcher_url="https://raw.githubusercontent.com/$REPO/$version/openRepoShape" - curl -fsSL "$launcher_url" -o "$WORKDIR/openRepoShape" || status=$? - [ "$status" -eq 0 ] && [ -s "$WORKDIR/openRepoShape" ] || - die "could not fetch $launcher_url (curl exit $status), so nothing was installed. Is $version a release of $REPO? https://github.com/$REPO/releases" + curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 "$launcher_url" -o "$WORKDIR/openRepoShape" || status=$? + # WHICH FAILURE IT WAS DECIDES WHAT TO SAY. curl's exit 22 is the server + # answering with an error, which for a raw URL means no such file at that + # ref; anything else is curl getting no answer at all (DNS, a proxy, TLS, + # a timeout), where asking whether the release exists would send the + # person looking in the wrong place. + case "$status" in + 0) [ -s "$WORKDIR/openRepoShape" ] || + die "fetched $launcher_url, but it was empty, so nothing was installed." ;; + 22) die "could not fetch $launcher_url: the server answered with an error (curl exit 22), so nothing was installed. Is $version a release of $REPO? https://github.com/$REPO/releases" ;; + *) die "could not fetch $launcher_url: curl got no answer (curl exit $status), so nothing was installed. Check this machine's network, DNS and proxy, then run the line again." ;; + esac looks_like_the_launcher "$WORKDIR/openRepoShape" || die "$launcher_url is not the openRepoShape command (its first line is not #!/usr/bin/env bash, or it has no INSTALLABLES line), so nothing was installed." say "fetch: $launcher_url" bin_dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" target="$bin_dir/openRepoShape" - say "install: OPENREPOSHAPE_REF=$version openRepoShape --install" - OPENREPOSHAPE_REF="$version" "${BASH:-bash}" "$WORKDIR/openRepoShape" --install || + say "install: OPENREPOSHAPE_REF=$version OPENREPOSHAPE_RECORD_REF=1 openRepoShape --install" + OPENREPOSHAPE_REF="$version" OPENREPOSHAPE_RECORD_REF=1 \ + "${BASH:-bash}" "$WORKDIR/openRepoShape" --install || die "the $version openRepoShape's --install refused (exit $?); its own refusal above says why." # THE PIN, CHECKED BY ASKING THE INSTALLED COMMAND, with the variable @@ -326,36 +354,56 @@ main() { *"@ $version)") ;; *) pinned=0 - say "install: $target does not stay on $version: it says '${shown:-nothing}' to --version. A release older than v1.0.1 cannot record the ref it was installed at; export OPENREPOSHAPE_REF=$version to keep it there." + say "install: $target does not stay on $version: it says '${shown:-nothing}' to --version. A release older than v1.0.1 cannot record the tag it was installed at: install v1.0.1 or newer, or export OPENREPOSHAPE_REF=$version in your shell profile to keep it there." ;; esac if [ -n "${OPENREPOSHAPE_REF:-}" ] && [ "$OPENREPOSHAPE_REF" != "$version" ]; then - say "install: \$OPENREPOSHAPE_REF is set in this shell, and while it is, every run uses it instead of $version. Unset it to run $version." + say "install: \$OPENREPOSHAPE_REF is set in this shell, by your shell profile or by hand, and while it is, every run uses it instead of $version. Remove it from your shell profile to run $version." fi # 3. GIT-FILTER-REPO. filter_repo_step - # 4. THE PREFLIGHT, LAST, THROUGH THE INSTALLED COMMAND AND ITS RECORD. + # 4. THE PREFLIGHT, LAST, THROUGH THE INSTALLED COMMAND AND ITS RECORD, so + # its setup.sh is the release's. Where the command did not record the tag, + # the tag is handed to this one run instead, so that a command which + # follows main is still not checked with main's setup.sh. say "preflight: $target --preflight" status=0 - ( - unset OPENREPOSHAPE_REF - "${BASH:-bash}" "$target" --preflight - ) || status=$? + if [ "$pinned" -eq 1 ]; then + ( + unset OPENREPOSHAPE_REF + "${BASH:-bash}" "$target" --preflight + ) || status=$? + else + OPENREPOSHAPE_REF="$version" "${BASH:-bash}" "$target" --preflight || status=$? + fi - if [ "$status" -ne 0 ]; then - say "done: openRepoShape $version is installed at $target, but the preflight did not pass (exit $status); it names what is missing above." + # EXIT 1 IS THE PREFLIGHT'S "not ready", which names what is missing; any + # other status is a run that did not get that far (setup.sh could not be + # fetched, or there is no Python to run it), and its REFUSED line says so. + case "$status" in + 0) ;; + 1) + say "done: openRepoShape $version is installed at $target, but the preflight did not pass (exit 1); it names what is missing above." exit 1 - fi + ;; + *) + say "done: openRepoShape $version is installed at $target, but the preflight did not run to the end (exit $status); the REFUSED line above says why." + exit 1 + ;; + esac if [ "$pinned" -eq 0 ]; then say "done: openRepoShape $version is installed at $target and this machine is ready, but the command does not stay on $version (see above)." exit 1 fi - say "done: openRepoShape $version is installed at $target, and this machine is ready. Run the same line again to upgrade to the newest release." + say "done: openRepoShape $version is installed at $target, pinned to the release tag, and this machine is ready. Run the same line again to upgrade to the newest release." exit 0 } # `${1+"$@"}`, setup.sh's own spelling: no arguments is the one-line run, and -# that has to survive `set -u` under every bash this has to run on. -main ${1+"$@"} +# that has to survive `set -u` under every bash this has to run on. In +# BRACES, so the call is one compound command that bash reads to its closing +# brace before running any of it: a download cut anywhere inside this line +# is a syntax error, and runs nothing. +{ main ${1+"$@"}; } diff --git a/openRepoShape b/openRepoShape index 246bf217..bee32cde 100755 --- a/openRepoShape +++ b/openRepoShape @@ -4,7 +4,7 @@ # openRepoShape — the short way in: `openRepoShape Atlas --org `. # # Install it once, from anywhere, with no checkout and no clone. The newest -# release, pinned to it (`install.sh`, #564): +# release, pinned to its tag (`install.sh`, #564): # # curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash # @@ -39,13 +39,21 @@ SELF="${BASH_SOURCE[0]:-$0}" # chose: setup.sh is told it (`--shape-ref`) and `--doctor` checks its clone # out at it, where an unnamed `main` leaves both to the default branch. Two # things name one, and the first wins: `$OPENREPOSHAPE_REF`, set for a run, -# and the ref `--install` RECORDED beside this file (`$SELF.ref`, read by -# `read_recorded_ref` below, once `checked_ref` exists to check it). With +# and the ref `--install` RECORDED beside this file (`.ref`, read by +# `read_recorded_ref` below, once `ref_fault` exists to check it). With # neither, it is `main`, unnamed, exactly as before there was a record. REF="${OPENREPOSHAPE_REF:-main}" REF_NAMED=0 [ -z "${OPENREPOSHAPE_REF:-}" ] || REF_NAMED=1 +#: 1 when the ref in force came from the record, which is what lets a +#: command re-installing itself keep it with no opt-in (see `record_ref`). +REF_FROM_RECORD=0 + +#: Why the record beside this file was refused, said once the arguments are +#: read, so that `--help` still answers past a broken record. +RECORD_FAULT="" + #: The record's name is the command's own plus this, so the record of #: `~/.local/bin/openRepoShape` is `~/.local/bin/openRepoShape.ref`. RECORD_SUFFIX=".ref" @@ -113,14 +121,17 @@ opensoft/openRepoTools', and its own installer places them: $OPENREPOSHAPE_ORG the organisation, when --org is not given $OPENREPOSHAPE_BIN_DIR where --install puts it (default ~/.local/bin) $OPENREPOSHAPE_SETUP_SH run this setup.sh on disk instead of fetching one - -`--install` RECORDS THE REF it runs at, in `openRepoShape.ref` beside the -command it places: $OPENREPOSHAPE_REF, which the one-line installer -(install.sh) sets to the release it installs. The installed command then -stays on that ref: it fetches setup.sh at it, setup.sh clones the standard -at it (`--shape-ref`), and `--doctor` checks its clone out at it. -$OPENREPOSHAPE_REF set for one run still wins, and `--version` prints the -ref in force. Installed with no ref, it records none and follows main. + $OPENREPOSHAPE_RECORD_REF 1: --install records $OPENREPOSHAPE_REF (below) + +`--install` RECORDS A REF ONLY WHEN ASKED: with $OPENREPOSHAPE_RECORD_REF=1 +it writes $OPENREPOSHAPE_REF to `openRepoShape.ref` beside the command it +places, which is what the one-line installer (install.sh) does with the +release tag it installs. The installed command then stays on that ref: it +fetches setup.sh at it, setup.sh clones the standard at it (`--shape-ref`), +and `--doctor` checks its clone out at it. $OPENREPOSHAPE_REF set for one run +still wins, and `--version` prints the ref in force. A command re-installing +itself keeps its own record; any other install records nothing, removes a +record already there, and follows main. `--doctor [] [--json] [--placement-plan ]` runs `shape-doctor.py` over (default `.`) and prints one row per check and ONE verdict: @@ -189,6 +200,10 @@ workdir() { # curl is the fallback for a machine with no `gh`. Each attempt writes to a # file and is checked before its bytes are used, so a half-written first # attempt is never mistaken for a fetch. +# +# HTTPS ONLY, REDIRECTS INCLUDED, TLS 1.2 OR NEWER (#564, SonarCloud +# shell:S6506): the flags are written out on the curl line itself, never +# gathered into an array, so a reader and the analyser both see them there. fetch_from_repo() { local path="$1" dest="$2" if command -v gh >/dev/null 2>&1 && @@ -198,7 +213,8 @@ fetch_from_repo() { return 0 fi if command -v curl >/dev/null 2>&1 && - curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/$path" \ + curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + "https://raw.githubusercontent.com/$REPO/$REF/$path" \ >"$dest" 2>/dev/null && [ -s "$dest" ]; then return 0 fi @@ -329,23 +345,27 @@ machine_report() { # command copied away without its record follows `main` again, and says so in # `--version`. # -# THE REF RECORDED IS THE ONE IN FORCE: `$OPENREPOSHAPE_REF`, else the record -# beside the copy being run, so a pinned command re-installing itself stays -# pinned. With neither, any record already there is REMOVED, which is what -# makes an install at main follow main however the command was installed -# before. Every outcome is one line, and no ref and no record is no line at -# all. The record's old value is echoed only when it passes the same shape -# check, because a hand-edited file is not this command's to print. +# RECORDED ONLY WHEN ASKED (#564, the review on #577). `$OPENREPOSHAPE_REF` +# alone records nothing, because a script that sets it around `--install` +# for its own reasons is not asking for a pin: workBenches sets it to a value +# that names no ref at all, to make any fetch fail loudly, and a record of +# that would break every later run on every machine it installs to. So the +# ref in force is written only when `$OPENREPOSHAPE_RECORD_REF` is 1, as the +# one-line installer sets it, or when it came from the record beside the copy +# being run, so a pinned command re-installing itself stays pinned. Any other +# install is an install with no ref: it records nothing, and REMOVES a record +# already there, which makes an install at main follow main however the +# command was installed before. Every outcome is one line, and nothing to +# record and no record is no line at all. The record's old value is echoed +# only when it is a ref this command would accept, because a hand-edited +# file is not this command's to print. record_ref() { - local record="$1$RECORD_SUFFIX" was="" + local record="$1" recording="$2" was="" if [ -f "$record" ]; then was="$(< "$record")" || was="" - case "$was" in - '' | [!A-Za-z0-9]* | *..* | *[!A-Za-z0-9._/-]*) was="another ref" ;; - esac - [ "${#was}" -le 255 ] || was="another ref" + [ -z "$(ref_fault "$was" "it")" ] || was="another ref" fi - if [ "$REF_NAMED" -eq 0 ]; then + if [ "$recording" -eq 0 ]; then [ -e "$record" ] || return 0 rm -f -- "$record" say "openRepoShape: no longer pinned to ${was:-another ref}; it follows main, or \$OPENREPOSHAPE_REF where set (removed $record)" @@ -359,11 +379,20 @@ record_ref() { install_commands() { local dir="${OPENREPOSHAPE_BIN_DIR:-$HOME/.local/bin}" - local name target source verb placed=0 - # THE REF IS CHECKED BEFORE ANYTHING IS COLLECTED OR PLACED: it is about - # to be written down and read back by every later run, and a refusal - # after the copy would leave a new command beside an old record. - [ "$REF_NAMED" -eq 0 ] || checked_ref "$REF" + local name target source verb placed=0 recording=0 record + if [ "$REF_FROM_RECORD" -eq 1 ] || + { [ "$REF_NAMED" -eq 1 ] && [ "${OPENREPOSHAPE_RECORD_REF:-}" = 1 ]; }; then + recording=1 + fi + # THE RECORD SITS BESIDE THE FILE, symlinks followed, as it is read. + record="$(resolved "$dir/openRepoShape")$RECORD_SUFFIX" + # BOTH CHECKED BEFORE ANYTHING IS COLLECTED OR PLACED: a ref about to be + # written down and read back by every later run, and a record path this + # install could neither write nor remove. A refusal after the copy would + # leave a new command beside an old record. + [ "$recording" -eq 0 ] || checked_ref "$REF" + [ ! -e "$record" ] || [ -f "$record" ] || + die "$record is not a file, so this install could neither record its ref there nor remove it. Move it away, then run this again." collect_commands mkdir -p "$dir" for name in "${INSTALLABLES[@]}"; do @@ -382,7 +411,7 @@ install_commands() { done # The record belongs to the one command that reads it, and is written # only once that command is in place. - record_ref "$dir/openRepoShape" + record_ref "$record" "$recording" # THE COUNT, out loud, and kept at one installable (#92): it costs a line # and is what keeps $INSTALLABLES honest — a person reading N lines cannot # tell whether an N+1th was meant to be there. The sentence that made @@ -475,43 +504,94 @@ shape_checkout() { # this script has said a word; and only then the SHAPE, which is safe to echo # back because it has passed both. # -# WHOSE REF IT IS, AND THE WAY OUT, ARE ARGUMENTS (#564): `$OPENREPOSHAPE_REF` -# by default, or the record `--install` wrote, whose refusal also says how to -# replace it. An EMPTY ref is refused too, which no caller could reach before -# the record: an empty variable is no ref at all, while an empty file is one. -checked_ref() { - local ref="$1" what="${2:-\$OPENREPOSHAPE_REF}" out="${3:+ $3}" - [ -n "$ref" ] || - die "$what is empty, which is not a branch, tag or commit this tool will pass to \`git checkout\`.$out" - if [ "${#ref}" -gt 255 ]; then - die "$what is ${#ref} characters long; a branch, tag or commit this tool will pass to \`git checkout\` is at most 255 characters.$out" +# WHOSE REF IT IS IS AN ARGUMENT (#564): `$OPENREPOSHAPE_REF` by default, or +# the record `--install` wrote. `ref_fault` SAYS what is wrong and refuses +# nothing, so the record can be checked as it is read and refused only once +# the arguments are, after `--help` has had its chance; `checked_ref` is the +# refusing spelling. An EMPTY ref is a fault too, which no caller could reach +# before the record: an empty variable is no ref at all, while an empty file +# is one. +ref_fault() { + local ref="$1" what="$2" + if [ -z "$ref" ]; then + printf '%s' "$what is empty, which is not a branch, tag or commit this tool will pass to \`git checkout\`." + elif [ "${#ref}" -gt 255 ]; then + printf '%s' "$what is ${#ref} characters long; a branch, tag or commit this tool will pass to \`git checkout\` is at most 255 characters." + else + case "$ref" in + *[![:print:]]*) printf '%s' "$what carries a control character, which is not a branch, tag or commit this tool will pass to \`git checkout\`." ;; + [!A-Za-z0-9]* | *..* | *.lock | *[!A-Za-z0-9._/-]*) printf '%s' "$what is '$ref', which is not a branch, tag or commit this tool will pass to \`git checkout\`: a ref starts with a letter or digit, uses only letters, digits, '.', '_', '/', '-', has no '..' and does not end in '.lock'." ;; + esac fi - case "$ref" in - *[![:print:]]*) die "$what carries a control character, which is not a branch, tag or commit this tool will pass to \`git checkout\`.$out" ;; - esac - case "$ref" in - [!A-Za-z0-9]* | *..* | *.lock | *[!A-Za-z0-9._/-]*) die "$what is '$ref', which is not a branch, tag or commit this tool will pass to \`git checkout\`: a ref starts with a letter or digit, uses only letters, digits, '.', '_', '/', '-', has no '..' and does not end in '.lock'.$out" ;; - esac +} + +checked_ref() { + local fault + fault="$(ref_fault "$1" "${2:-\$OPENREPOSHAPE_REF}")" + [ -z "$fault" ] || die "$fault" +} + +# A PATH WITH ITS SYMLINKS FOLLOWED (#564, the review on #577). The record +# sits beside the FILE `--install` placed, so a link to the command from +# anywhere else (`~/bin/openRepoShape -> ~/.local/bin/openRepoShape`) must +# read the record there, not look for one beside the link. `readlink` with +# no `-f`, which macOS before 12.3 lacks, one link at a time and at most 40 +# of them, the kernel's own limit, so a loop of links ends. Where `readlink` +# fails or is missing the path is used as it is, which is where the record +# was looked for before. Git Bash makes copies for `ln -s` unless told +# otherwise, and a copy is no link. +resolved() { + local path="$1" link hops=0 + while [ -L "$path" ] && [ "$hops" -lt 40 ]; do + link="$(readlink -- "$path" 2>/dev/null)" && [ -n "$link" ] || break + case "$link" in + /*) path="$link" ;; + *) path="$(dirname -- "$path")/$link" ;; + esac + hops=$((hops + 1)) + done + printf '%s' "$path" } # THE RECORD `--install` LEFT BESIDE THIS FILE, READ ONCE, BEFORE ANY RUN # (#564). Only when no `$OPENREPOSHAPE_REF` was set, which is what lets that # variable win for a run, and only beside a FILE: a script arriving on stdin -# has no place of its own to have been installed at. CHECKED HERE, AT ONCE, -# rather than where the ref is used: it is a file on disk that every run +# has no place of its own to have been installed at. CHECKED HERE, as it is +# read, rather than where the ref is used: it is a file on disk that every run # reads, `--version` prints it, and a value nobody typed is not echoed until -# it has passed. `$(< file)` is bash's own read, so no `cat` is needed. +# it has passed. A record that fails is NOT refused here but kept in +# `$RECORD_FAULT`, and `refuse_bad_record` refuses with it once the arguments +# have been read: `--help` answers past a broken record, and every run that +# would use the ref refuses, `--version` first. `$(< file)` is bash's own +# read, so no `cat` is needed; a record that is not a readable FILE (a +# directory, say) is a fault in the same words as the rest, never a raw bash +# error. +RECORD_WAY_OUT="Run the installer again to record a release, or delete that file to follow main." read_recorded_ref() { - local record="$SELF$RECORD_SUFFIX" - [ "$REF_NAMED" -eq 0 ] && [ -f "$SELF" ] && [ -f "$record" ] || return 0 - REF="$(< "$record")" || - die "could not read $record, the ref \`--install\` recorded. Run the installer again, or delete that file to follow main." - checked_ref "$REF" "the ref recorded in $record" \ - "Run the installer again to record a release, or delete that file to follow main." + local record text fault + [ "$REF_NAMED" -eq 0 ] && [ -f "$SELF" ] || return 0 + record="$(resolved "$SELF")$RECORD_SUFFIX" + [ -e "$record" ] || return 0 + if [ ! -f "$record" ] || [ ! -r "$record" ]; then + RECORD_FAULT="$record is not a file this command can read, so it cannot read the ref \`--install\` recorded there. Remove it, then run the installer again to record a release, or leave it removed to follow main." + return 0 + fi + text="$(< "$record")" + fault="$(ref_fault "$text" "the ref recorded in $record")" + if [ -n "$fault" ]; then + RECORD_FAULT="$fault $RECORD_WAY_OUT" + return 0 + fi + REF="$text" REF_NAMED=1 + REF_FROM_RECORD=1 } read_recorded_ref +refuse_bad_record() { + [ -z "$RECORD_FAULT" ] || die "$RECORD_FAULT" +} + # `--doctor`: RUN THE STANDARD'S OWN SCRIPT, never a reimplementation here. # Beside a checkout -- this file sitting in one, which is how a developer runs # it -- that checkout answers, and nothing is fetched at all. Installed, or @@ -603,7 +683,7 @@ run_doctor() { while [ $# -gt 0 ]; do case "$1" in -h | --help) usage; exit 0 ;; - --version) say "openRepoShape ($REPO @ $REF)"; exit 0 ;; + --version) refuse_bad_record; say "openRepoShape ($REPO @ $REF)"; exit 0 ;; # TWO MODES IS A REFUSAL, NEVER LAST-WINS. `--doctor --install` # used to enter the install path and silently drop the diagnosis, and # the other order dropped the install (Copilot, PR #96). Each of these @@ -696,6 +776,10 @@ while [ $# -gt 0 ]; do esac done +# A BROKEN RECORD IS REFUSED HERE, after `--help` has had its chance and +# before any run that would use the ref it should have named (#564). +refuse_bad_record + if [ "$MODE" = "doctor" ]; then # ONE PATH AND THE DOCTOR'S TWO FLAGS, NOTHING ELSE. Every other # argument this command knows is about a run that CREATES something, and diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index 7ced9afc..34c7de5a 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -57,6 +57,11 @@ RELEASE = "v9.9.9" FILTER_REPO = "git-filter-repo==2.47.0" +#: The flags every curl in the installer and the launcher carries, as the +#: fakes log them once the shell has taken the quotes off (SonarCloud +#: shell:S6506: HTTPS only, redirects included, TLS 1.2 or newer). +HTTPS_ONLY = "--proto =https --proto-redir =https --tlsv1.2" + def latest(slug: str = SLUG) -> str: return f"https://github.com/{slug}/releases/latest" @@ -104,12 +109,16 @@ def launcher(directory, name): def curl(): url = next((arg for arg in ARGS if arg.startswith("https://")), "") + if url in NET.get("unreachable", []): + sys.stderr.write("curl: (6) Could not resolve host\n") + return 6 if "-fsSI" in ARGS: location = NET["redirects"].get(url) if location is None: return refuse("curl: (22) The requested URL returned error: 404") - sys.stdout.write("HTTP/2 302 \r\nlocation: %s\r\n" - "content-length: 0\r\n\r\n" % location) + sys.stdout.write("HTTP/2 302 \r\n%s: %s\r\n" + "content-length: 0\r\n\r\n" + % (NET.get("location_header", "location"), location)) return 0 served = NET["files"].get(url) if served is None: @@ -162,6 +171,7 @@ def setup_sh(): case "$1" in --install) mkdir -p "$OPENREPOSHAPE_BIN_DIR" && cp "$0" "$OPENREPOSHAPE_BIN_DIR/openRepoShape" ;; --version) printf 'openRepoShape (%s @ %s)\\n' "$OPENREPOSHAPE_REPO" "${OPENREPOSHAPE_REF:-main}" ;; +--preflight) printf 'old preflight at %s\\n' "${OPENREPOSHAPE_REF:-main}" ;; esac """ @@ -214,18 +224,21 @@ def serve(self, ref: str, slug: str = SLUG, self.config["files"][raw("setup.sh", ref, slug)] = str( self.fake("setup.sh", served)) - def run(self, *args: str, piped: bool = False, - env: dict | None = None) -> subprocess.CompletedProcess: - """`bash install.sh `, or `… | bash -s -- ` when piped.""" + def run(self, *args: str, piped: bool = False, env: dict | None = None, + script: str | None = None) -> subprocess.CompletedProcess: + """`bash install.sh `, or `… | bash -s -- ` when piped; + `script` pipes that text in place of the installer's own.""" (self.root / "net.json").write_text(json.dumps(self.config), encoding="utf-8") gh_config = self.tmp_path / "gh-config" gh_config.mkdir(exist_ok=True) + piped = piped or script is not None argv = [BASH, "-s", "--", *args] if piped \ else [BASH, str(INSTALLER), *args] return subprocess.run( argv, capture_output=True, text=True, check=False, - input=INSTALLER.read_text(encoding="utf-8") if piped else "", + input=(script or INSTALLER.read_text(encoding="utf-8")) + if piped else "", cwd=str(self.tmp_path), start_new_session=True, env=command_env(home=self.tmp_path, setup_sh=None, env={ "PATH": str(self.path), "GH_TOKEN": "bogus", @@ -283,22 +296,24 @@ def test_the_one_line_installs_the_newest_release_pinned_to_it(net): steps = [ f"release: {RELEASE}, the newest release of {SLUG} ({latest()})", f"fetch: {raw('openRepoShape')}", - f"install: OPENREPOSHAPE_REF={RELEASE} openRepoShape --install", + f"install: OPENREPOSHAPE_REF={RELEASE} OPENREPOSHAPE_RECORD_REF=1 " + "openRepoShape --install", f"openRepoShape: pinned to {RELEASE}, recorded in {target}.ref", "git-filter-repo: ", f"preflight: {target} --preflight", - f"done: openRepoShape {RELEASE} is installed at {target}, and this " - "machine is ready. Run the same line again to upgrade to the newest " - "release.", + f"done: openRepoShape {RELEASE} is installed at {target}, pinned to " + "the release tag, and this machine is ready. Run the same line again " + "to upgrade to the newest release.", ] lines = result.stdout.splitlines() at = [next(i for i, line in enumerate(lines) if line.startswith(step)) for step in steps] assert at == sorted(at), result.stdout calls = net.calls() - assert calls[0] == f"curl -fsSI {latest()}" - assert calls[1].startswith(f"curl -fsSL {raw('openRepoShape')} -o ") - assert f"curl -fsSL {raw('setup.sh')}" in calls + assert calls[0] == f"curl -fsSI {HTTPS_ONLY} {latest()}" + assert calls[1].startswith( + f"curl -fsSL {HTTPS_ONLY} {raw('openRepoShape')} -o ") + assert f"curl -fsSL {HTTPS_ONLY} {raw('setup.sh')}" in calls assert calls[-1] == preflight_call(), calls assert not any("releases/latest" in call for call in calls[1:]), calls @@ -375,15 +390,30 @@ def test_a_version_that_is_not_a_ref_is_refused_before_any_request( net.refused(result) assert said in result.stderr assert net.calls() == [] - assert "forged" not in result.stderr and "a" * 256 not in result.stderr + assert "forged" not in result.stderr + assert "a" * 256 not in result.stderr def test_a_failed_fetch_names_its_url_and_installs_nothing(net): + """An error from the server asks whether the release exists.""" del net.config["files"][raw("openRepoShape")] result = net.run() net.refused(result) - assert f"could not fetch {raw('openRepoShape')} (curl exit 22), so " \ - "nothing was installed" in result.stderr + assert f"could not fetch {raw('openRepoShape')}: the server answered " \ + "with an error (curl exit 22), so nothing was installed. Is " \ + f"{RELEASE} a release of {SLUG}?" in result.stderr + + +def test_a_fetch_with_no_answer_points_at_the_network_not_the_release(net): + """No answer at all (DNS, a proxy, TLS) is not a question about the + release, and the refusal does not send anyone looking there.""" + net.config["unreachable"] = [raw("openRepoShape")] + result = net.run() + net.refused(result) + assert f"could not fetch {raw('openRepoShape')}: curl got no answer " \ + "(curl exit 6), so nothing was installed. Check this machine's " \ + "network, DNS and proxy" in result.stderr + assert "a release of" not in result.stderr def test_a_page_that_is_not_the_command_is_refused(net): @@ -439,20 +469,29 @@ def no_pipx(net): def no_pipx_but_brew(net): no_pipx(net) net.fake("brew") + net.fake("apt-get") return " brew install git-filter-repo" +def no_pipx_but_apt(net): + no_pipx(net) + net.fake("apt-get") + return f" sudo apt-get install -y pipx && pipx install {FILTER_REPO}" + + @pytest.mark.parametrize("arrange", [ present_on_path, answering_git, pipx_onto_path, pipx_off_path, - pipx_failing, no_pipx, no_pipx_but_brew]) + pipx_failing, no_pipx, no_pipx_but_brew, no_pipx_but_apt]) def test_git_filter_repo_is_said_installed_or_printed(net, arrange): """The three branches, and the ways each can end. PRESENT, on `$PATH` or as `git filter-repo`: said, and pipx is never run. NOT PRESENT AND PIPX IS THERE: said first, then `pipx install` at CI's version, then what came of it. NEITHER: the one command for this - machine, printed and never run. None changes the exit status, and the - step sits between the install and the preflight. + machine, printed and never run: brew's where brew is, pipx from apt where + apt-get is (Debian 12 and Ubuntu 23.04 on refuse `pip install --user`), + and pip otherwise. Nothing runs `sudo` or `apt-get`. None changes the + exit status, and the step sits between the install and the preflight. """ expected = arrange(net) result = net.run() @@ -470,26 +509,35 @@ def test_git_filter_repo_is_said_installed_or_printed(net, arrange): net.calls().index(preflight_call()) else: assert ran_pipx == [], net.calls() - if arrange in (no_pipx, no_pipx_but_brew): + if arrange in (no_pipx, no_pipx_but_brew, no_pipx_but_apt): assert lines[lines.index(expected) - 1] == ( "git-filter-repo: not installed, and there is no pipx to install " "it with. Only adopt-project.py needs it; before adopting a " "repository, run:") assert lines.index(expected) < lines.index( f"preflight: {net.bin}/openRepoShape --preflight") + assert not any(call.split()[0] in ("apt-get", "brew", "sudo") + for call in net.calls()), net.calls() # --- the preflight, the exit status, and running it again -------------------- +@pytest.mark.parametrize("status, said", [ + pytest.param(1, "but the preflight did not pass (exit 1); it names what " + "is missing above.", id="not-ready"), + pytest.param(2, "but the preflight did not run to the end (exit 2); the " + "REFUSED line above says why.", id="did-not-run"), +]) def test_a_preflight_that_does_not_pass_exits_one_with_the_command_placed( - net): - net.config["preflight_exit"] = 1 + net, status, said): + """Exit 1 is the preflight's own "not ready", which names what is + missing; anything else stopped before it could say.""" + net.config["preflight_exit"] = status result = net.run() assert result.returncode == 1, result.stdout + result.stderr net.installed(result) assert f"done: openRepoShape {RELEASE} is installed at " \ - f"{net.bin}/openRepoShape, but the preflight did not pass (exit 1); " \ - "it names what is missing above." in result.stdout + f"{net.bin}/openRepoShape, {said}" in result.stdout def test_a_variable_in_the_shell_is_named_and_the_preflight_runs_the_pin(net): @@ -499,8 +547,9 @@ def test_a_variable_in_the_shell_is_named_and_the_preflight_runs_the_pin(net): result = net.run(env={"OPENREPOSHAPE_REF": "v0.0.1"}) assert result.returncode == 0, result.stdout + result.stderr net.installed(result) - assert f"install: $OPENREPOSHAPE_REF is set in this shell, and while it " \ - f"is, every run uses it instead of {RELEASE}. Unset it to run " \ + assert "install: $OPENREPOSHAPE_REF is set in this shell, by your " \ + "shell profile or by hand, and while it is, every run uses it " \ + f"instead of {RELEASE}. Remove it from your shell profile to run " \ f"{RELEASE}." in result.stdout assert net.calls()[-1] == preflight_call() @@ -528,7 +577,9 @@ def test_the_same_line_again_changes_nothing_and_then_upgrades(net): def test_a_release_whose_command_records_no_ref_exits_one(net): """A release before v1.0.1 installs a command that follows main. The - installer asks the installed command, says so, and exits 1.""" + installer asks the installed command, says so, and exits 1; and the + preflight it ends with is still handed the release's tag, so it is not + main's setup.sh that checks the machine.""" old = net.root / "old-openRepoShape" old.write_text(OLD_LAUNCHER, encoding="utf-8") net.serve(RELEASE, launcher=old) @@ -538,11 +589,86 @@ def test_a_release_whose_command_records_no_ref_exits_one(net): assert f"install: {net.bin}/openRepoShape does not stay on {RELEASE}: " \ f"it says 'openRepoShape ({SLUG} @ main)' to --version." \ in result.stdout - assert f"export OPENREPOSHAPE_REF={RELEASE}" in result.stdout + assert "A release older than v1.0.1 cannot record the tag it was " \ + "installed at: install v1.0.1 or newer, or export " \ + f"OPENREPOSHAPE_REF={RELEASE} in your shell profile to keep it " \ + "there." in result.stdout + assert f"old preflight at {RELEASE}" in result.stdout.splitlines() assert result.stdout.splitlines()[-1].endswith( f"but the command does not stay on {RELEASE} (see above).") +@pytest.mark.parametrize("header, location", [ + pytest.param("Location", tag_page(RELEASE), id="upper-case"), + pytest.param("location", f"/{SLUG}/releases/tag/{RELEASE}", + id="relative"), +]) +def test_the_redirect_is_read_in_any_case_and_relative(net, header, location): + """HTTP/1.1 may send `Location:`, and a location may be relative; both + name the release the same way.""" + net.config["location_header"] = header + net.config["redirects"][latest()] = location + result = net.run() + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert f"release: {RELEASE}, the newest release of {SLUG} ({latest()})" \ + in result.stdout.splitlines() + + +def test_openreposhape_bin_dir_is_where_the_command_and_its_record_go(net): + """The installer places, pins and preflights the command where the + launcher puts it, and leaves `~/.local/bin` alone.""" + default = net.bin + net.bin = net.tmp_path / "elsewhere" + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": str(net.bin)}) + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + assert f"preflight: {net.bin}/openRepoShape --preflight" \ + in result.stdout.splitlines() + assert not default.exists() + + +#: A curl that RUNS, in either script: tab-indented, alone or as the value +#: of an assignment. Usage text and refusals carry curl lines too, indented +#: with spaces, and are printed, never run. +CURL_RUN = re.compile(r"\t+(\w+=\"\$\()?curl -fsS") + + +@pytest.mark.parametrize("name", ["install.sh", "openRepoShape"]) +def test_every_curl_that_runs_is_https_only_redirects_included(name): + """SonarCloud shell:S6506, held here rather than found there: each curl + line refuses any protocol but HTTPS, for its redirects too, written out + on the line itself.""" + runs = [line for line in (REPO / name).read_text( + encoding="utf-8").splitlines() if CURL_RUN.match(line)] + assert runs, f"{name} runs no curl; this guard has nothing to hold" + for line in runs: + assert "--proto '=https' --proto-redir '=https' --tlsv1.2" in line, ( + f"{name}: a curl that is not HTTPS only:\n{line}") + + +#: Where a download can stop in the last line. Right after the word `main` +#: is the one the braces are for: unbraced, that line would run `main` with +#: none of the arguments, and install the newest release where one was named. +CUTS = [ + pytest.param(lambda text: text[:text.rindex("{ main")], id="before-it"), + pytest.param(lambda text: text[:text.rindex(" ${1+")], id="after-main"), + pytest.param(lambda text: text[:text.rindex(" }")], id="before-the-brace"), +] + + +@pytest.mark.parametrize("cut", CUTS) +def test_a_download_cut_off_in_the_last_line_runs_nothing(net, cut): + """Everything runs from `main`, called on the last line inside braces: + a script that stops anywhere in or before that line asks for nothing, + installs nothing and leaves nothing behind.""" + result = net.run("v9.9.8", + script=cut(INSTALLER.read_text(encoding="utf-8"))) + assert net.calls() == [] + assert result.stdout == "" + assert not net.bin.exists() + + # --- the repository it installs from ---------------------------------------- def test_openreposhape_repo_names_where_everything_comes_from(net): @@ -556,7 +682,8 @@ def test_openreposhape_repo_names_where_everything_comes_from(net): assert result.returncode == 0, result.stdout + result.stderr net.installed(result) assert all(SLUG not in call for call in net.calls()), net.calls() - assert f"curl -fsSL {raw('setup.sh', RELEASE, fork)}" in net.calls() + assert f"curl -fsSL {HTTPS_ONLY} {raw('setup.sh', RELEASE, fork)}" \ + in net.calls() @pytest.mark.parametrize("value", [ @@ -593,4 +720,5 @@ def test_it_installs_the_git_filter_repo_ci_runs(): ci = re.findall(r"git-filter-repo==[0-9.]+", ( REPO / ".github" / "workflows" / "tests.yml").read_text( encoding="utf-8")) - assert ci and set(ci) == {pinned}, (pinned, ci) + assert ci, "tests.yml installs no git-filter-repo" + assert set(ci) == {pinned}, (pinned, ci) diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index 59cb0c00..78a66ab0 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -98,7 +98,8 @@ def command_env(home: Path | None = None, env: dict | None = None, """ environ = dict(os.environ) for name in ("OPENREPOSHAPE_ORG", "OPENREPOSHAPE_REF", "OPENREPOSHAPE_REPO", - "OPENREPOSHAPE_BIN_DIR", "OPENREPOSHAPE_SETUP_SH"): + "OPENREPOSHAPE_BIN_DIR", "OPENREPOSHAPE_SETUP_SH", + "OPENREPOSHAPE_RECORD_REF"): environ.pop(name, None) if setup_sh is not None: environ["OPENREPOSHAPE_SETUP_SH"] = str(setup_sh) @@ -1018,14 +1019,17 @@ def test_install_from_stdin_fetches_itself_into_a_live_workdir(offline_github, OTHER = "other" -def install_at(tmp_path, ref=None, command=COMMAND): +def install_at(tmp_path, ref=None, command=COMMAND, opt_in=True): """`--install` from `command` into `/bin`, with - `$OPENREPOSHAPE_REF` set to `ref` when one is given; the result, and the - path of the command it places.""" + `$OPENREPOSHAPE_REF` set to `ref` when one is given, and asked to record + it (`$OPENREPOSHAPE_RECORD_REF=1`) unless `opt_in` is false; the result, + and the path of the command it places.""" bin_dir = tmp_path / "bin" env = {"OPENREPOSHAPE_BIN_DIR": str(bin_dir)} if ref is not None: env["OPENREPOSHAPE_REF"] = ref + if opt_in: + env["OPENREPOSHAPE_RECORD_REF"] = "1" return (run_cmd("--install", home=tmp_path, env=env, command=command), bin_dir / "openRepoShape") @@ -1147,12 +1151,78 @@ def test_a_record_that_is_not_a_ref_is_refused_by_name(tmp_path, text, shown): in result.stderr assert "Run the installer again to record a release, or delete that " \ "file to follow main." in result.stderr - assert "forged" not in result.stderr and "a" * 256 not in result.stderr + assert "forged" not in result.stderr + assert "a" * 256 not in result.stderr assert result.stdout == "" + assert run_cmd("--help", command=installed).returncode == 0, ( + "--help must answer past a broken record") assert version_of(installed, {"OPENREPOSHAPE_REF": OTHER}).endswith( f"@ {OTHER})") +def test_a_ref_set_without_the_opt_in_is_never_recorded(tmp_path): + """`$OPENREPOSHAPE_REF` alone is not a request for a pin (the review on + #577). A script that sets it around `--install` for its own reasons + (workBenches sets one that names no ref, so that any fetch fails loudly) + gets the install it always got: the same bytes, no record, `main` in + `--version`, and no check of a value it never meant to be read. A record + already there is removed, exactly as by an install with no ref.""" + install_at(tmp_path, PINNED) + result, installed = install_at( + tmp_path, "pinned-by-workBenches-no-fetch", opt_in=False) + assert result.returncode == 0, result.stderr + assert installed.read_bytes() == COMMAND.read_bytes() + assert not record_of(installed).exists() + assert f"no longer pinned to {PINNED}" in result.stdout + assert version_of(installed).endswith("@ main)") + + again, _ = install_at(tmp_path, "bad..ref", opt_in=False) + assert again.returncode == 0, again.stderr + assert "pinned" not in again.stdout + assert not record_of(installed).exists() + + +#: Links to the installed command from `/links`: each pair is a link's +#: name and what it points at, `None` meaning the command's absolute path, +#: and the run goes through the LAST one. +LINKS = [ + pytest.param([("ors", "../bin/openRepoShape")], id="relative"), + pytest.param([("ors", None)], id="absolute"), + pytest.param([("ors", "../bin/openRepoShape"), ("again", "ors")], + id="a-chain"), +] + + +@pytest.mark.parametrize("links", LINKS) +def test_a_link_to_the_installed_command_reads_its_record(tmp_path, links): + """The record sits beside the FILE `--install` placed, so a symlink to + the command from another directory follows the link, or a chain of them, + to read it.""" + _result, installed = install_at(tmp_path, PINNED) + directory = tmp_path / "links" + directory.mkdir() + for name, points_at in links: + (directory / name).symlink_to(points_at or installed) + through = directory / links[-1][0] + assert version_of(through) == \ + f"openRepoShape (opensoft/openRepoShape @ {PINNED})" + + +def test_a_record_that_is_a_directory_is_refused_in_the_usual_form(tmp_path): + """Not a raw bash error, by every run that reads it and by an install + that would write or remove it, which places nothing.""" + _result, installed = install_at(tmp_path, PINNED) + record = record_of(installed) + record.unlink() + record.mkdir() + for result in (run_cmd("--version", command=installed), + install_at(tmp_path, PINNED)[0]): + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith(f"\nREFUSED: {record} is not a file") + assert "Is a directory" not in result.stderr + assert installed.read_bytes() == COMMAND.read_bytes() + + #: WHICH REF EACH LATER RUN USES: recorded at install, then overridden by the #: variable for one run, then neither. The third column is what the run must #: be handed; `None` is `main`, unnamed, which nothing is told. From 322a305e8c6c3fdf7fa1f718ce9ff27e7c9ea708 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:21:55 +0000 Subject: [PATCH 04/58] README.md, docs/handbook.html, AGENTS.md and the v1.0.1 release notes, the fix round on #577: install.sh itself never runs sudo while the preflight it ends with offers what is missing behind a typed yes, the command is pinned to the release tag only when asked, step 3 folds the pin into one paragraph six lines shorter, AGENTS.md says the one-line install is the human's to run, and the notes gain the steps that attach install.sh to the release (#564) - README.md: step 3's paragraph now carries what "pinned" means and the corrected sudo sentence, and leaves the exit statuses to `install.sh --help` and the release notes; the worked example's lead-in is one line. The cap moves to 1444 (from 1450 in d0cdbf4), with the reason updated in test_readme_is_short_enough_to_be_read. - docs/handbook.html: the same paragraph, the same rule in "For AI assistants", the #564 footer clause updated, and the new README blob. - AGENTS.md: "The one-line install (`install.sh`) is theirs to run: relay it, never run it." under "Never answer an install prompt", paid for by tightening the `--allow-upstream-org` paragraph's middle clause; still 490 lines, every rule kept. - docs/release-notes/v1.0.1.md: the corrected sudo sentence, the opt-in (`OPENREPOSHAPE_RECORD_REF=1`) and what a script that exports `$OPENREPOSHAPE_REF` now gets, HTTPS only, Git Bash supported but its tests skip on the Windows job (a known limit, followed up in v1.1.0), and a "Before tagging (remove at tag time)" section: upload install.sh to v1.0.1, check the `releases/latest/download/install.sh` redirect, never attach it to v1.0.0, and publish a later patch on an older line with `--latest=false`. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 ++--- README.md | 24 +++++------- docs/handbook.html | 38 ++++++++---------- docs/release-notes/v1.0.1.md | 75 +++++++++++++++++++++++++----------- tests/test_repo_hygiene.py | 34 ++++++++-------- 5 files changed, 101 insertions(+), 80 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e579245c..993d2d21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,13 +25,13 @@ types `yes` at its prompt; you never answer it for them. No terminal: section 2. **Never answer an install prompt.** The preflight asks `Type yes to continue:` for each thing it offers to run — an install, `gh auth login`, `gh auth setup-git`. The human types it; you never install software or log in for them. +The one-line install (`install.sh`) is theirs to run: relay it, never run it. **Never pass `--allow-upstream-org` on your own initiative.** It guards only -`--org opensoft` itself — scaffolding into opensoft's own namespace, the -upstream owner of openRepoShape. If setup.sh refuses because the organisation -you were given resolves to opensoft, STOP and relay what it said; the fix is -almost always an explicit `--org `. Pass `--allow-upstream-org` -only if the human asks for it in those words. +`--org opensoft`, the namespace of openRepoShape's upstream owner. If setup.sh +refuses because the organisation you were given resolves to opensoft, STOP and +relay what it said; the fix is almost always an explicit `--org `. +Pass `--allow-upstream-org` only if the human asks for it in those words. **Never bypass an organisation ruleset.** A ruleset that refuses a direct push to `main` is doing its job; the exit is in the tool's own failure message (for diff --git a/README.md b/README.md index 147b3cb5..70ded88c 100644 --- a/README.md +++ b/README.md @@ -231,19 +231,14 @@ three repositories are created and the push fails. curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash ``` -`install.sh` is attached to every release, so that line installs the newest: -it reads which one that is from github.com's `releases/latest` redirect (no -`gh`, no login), installs that release's command PINNED to it, installs -`git-filter-repo` with `pipx` when `pipx` is there and it is not, or prints -the one command that would, and ends with `openRepoShape --preflight`. It -never runs `sudo`. `| bash -s -- v1.0.1` installs a named release instead. -Exit 0 is installed and ready, 1 installed but the preflight or the pin needs -you, 2 nothing installed. Run the same line again to upgrade. - -**Pinned** means the command stays on its release while main moves on: -`--install` records the ref in `openRepoShape.ref` beside the command, and the -command fetches `setup.sh`, clones the standard and runs `--doctor` at it. -`$OPENREPOSHAPE_REF` overrides it for one run; `--version` prints the ref. +`install.sh` is attached to every release and installs the newest, with no `gh` +and no login. Its command is PINNED to the release tag, recorded in +`openRepoShape.ref` beside it: `setup.sh` and the standard are fetched at that +tag, not at main. It installs `git-filter-repo` with `pipx` where it can, or +prints the command, and ends with `openRepoShape --preflight`. `install.sh` +itself never runs `sudo`; the preflight it ends with offers what is missing, +each behind a typed `yes` at your terminal. `| bash -s -- v1.0.1` names a +release; the same line again upgrades. By hand, the same command at main, which records no ref and follows main: @@ -400,8 +395,7 @@ records as whose act the election was, and it defaults to the `gh` login. #### Install the command -Two ways in, the short one first. Install `openRepoShape` as a command, the -newest release and pinned to it, as step 3 above does: +Two ways in, the short one first. Install the command as step 3 above does: ```sh curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash diff --git a/docs/handbook.html b/docs/handbook.html index 377f934a..84d28811 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -488,8 +488,8 @@

The fastest path — macOS, Linux, WSL2

curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
 openRepoShape <Project> --org <your-org>

That first line installs ONE command into ~/.local/bin: the - newest release, pinned to it, with git-filter-repo where pipx can - install it, and it ends with openRepoShape --preflight. The + newest release, pinned to its tag, with git-filter-repo where pipx + can install it, and it ends with openRepoShape --preflight. The estate verbs park and resume, for carrying in-flight work to another workstation, are opensoft/openRepoTools’ and have a one-liner of @@ -756,21 +756,15 @@

Install the openRepoShape command, on

Install the openRepoShape command, once, inside WSL

Fetch the flow, in the directory the new project should land in

curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
-

install.sh is attached to every release, so - that line installs the newest: it reads which one that is from github.com’s - releases/latest redirect (no gh, no login), installs that - release’s command PINNED to it, installs git-filter-repo with - pipx when pipx is there and it is not, or prints the one command - that would, and ends with openRepoShape --preflight. It never runs - sudo. | bash -s -- v1.0.1 installs a named release instead. Exit - 0 is installed and ready, 1 installed but the preflight or the pin needs you, 2 nothing - installed. Run the same line again to upgrade.

-

Pinned means the command stays on its - release while main moves on: --install records the ref in - openRepoShape.ref beside the command, and the command fetches - setup.sh, clones the standard and runs --doctor at it. - $OPENREPOSHAPE_REF overrides it for one run; --version prints the - ref.

+

install.sh is attached to every release and + installs the newest, with no gh and no login. Its command is PINNED to the + release tag, recorded in openRepoShape.ref beside it: setup.sh + and the standard are fetched at that tag, not at main. It installs + git-filter-repo with pipx where it can, or prints the command, + and ends with openRepoShape --preflight. install.sh itself never + runs sudo; the preflight it ends with offers what is missing, each behind a + typed yes at your terminal. | bash -s -- v1.0.1 names a + release; the same line again upgrades.

By hand, the same command at main, which records no ref and follows main:

gh api repos/opensoft/openRepoShape/contents/openRepoShape \
     -H 'Accept: application/vnd.github.raw' | bash -s -- --install
@@ -1818,7 +1812,8 @@

For AI assistants

  • Never answer an install prompt. The preflight asks Type yes to continue: for each thing it offers to run — an install, gh auth login, gh auth setup-git. The human types it; you never - install software or log in for them.
  • + install software or log in for them. The one-line install (install.sh) is + theirs to run: relay it, never run it.
  • Never pass --allow-upstream-org on your own initiative. If setup.sh refuses because the organisation you were given resolves to opensoft, STOP and relay what it said; the fix is almost always an explicit --org <their-org>.
  • @@ -1906,10 +1901,11 @@

    For AI assistants

    merged-away verdict, whose row apply drops, added (#502); the line-ending block an adoption appends to a root .gitattributes the source keeps, and its verification as the source’s bytes plus that block, added (#471); the one-line - install, install.sh, which installs the newest release pinned to it, and the - command’s openRepoShape.ref record, added (#564) — a page + install, install.sh, which installs the newest release pinned to its tag, the + command’s openRepoShape.ref record, and the assistant’s rule that the + line is the human’s to run, added (#564) — a page cannot name the commit that contains it, and the blob below is the fact a test checks. - README.md blob 147b3cb5c1d91c96196d6cef09ba984cfa0f6576. Every command, flag, exit code + README.md blob 70ded88ce2ef4b0cd8faf4100d53fcd98d0da522. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

    diff --git a/docs/release-notes/v1.0.1.md b/docs/release-notes/v1.0.1.md index a5c5e3cb..b165c364 100644 --- a/docs/release-notes/v1.0.1.md +++ b/docs/release-notes/v1.0.1.md @@ -1,7 +1,7 @@ # openRepoShape v1.0.1 v1.0.1 is v1.0.0 plus one change: openRepoShape installs in one line, and the -command it installs stays on the release it was installed from (#564). +command it installs stays on the release tag it was installed from (#564). ## What 1.0.1 adds @@ -15,24 +15,26 @@ command it installs stays on the release it was installed from (#564). It reads which release is the newest from github.com's `releases/latest` redirect, with no `gh` and no login. It fetches that release's `openRepoShape` and installs it into `~/.local/bin`, or - `$OPENREPOSHAPE_BIN_DIR`, pinned to the release. It installs + `$OPENREPOSHAPE_BIN_DIR`, pinned to the release tag. It installs `git-filter-repo` 2.47.0 with `pipx` when `pipx` is there and `git-filter-repo` is not, and otherwise prints the one command that - installs it. It ends with `openRepoShape --preflight`. It never runs `sudo` - and installs nothing else. It exits 0 when the command is installed and the - preflight passed, 1 when the command is installed but the preflight did not - pass or the command does not stay on the release, and 2 when it installed - nothing. -- **An installed command that stays on its release.** `openRepoShape - --install` records the ref it runs at, `$OPENREPOSHAPE_REF`, in - `openRepoShape.ref` beside the command it places. The installed command then - fetches `setup.sh` at that ref, `setup.sh` clones the standard at it - (`--shape-ref`), and `--doctor` checks its clone out at it. In v1.0.0 an - installed command fetched main on every run unless `$OPENREPOSHAPE_REF` was - exported. `$OPENREPOSHAPE_REF` set for one run still overrides the record, - and `openRepoShape --version` prints the ref in force. An install with no - ref records nothing, removes any record already there, and follows main. The - installed command is byte for byte the release's own file. + installs it. It ends with `openRepoShape --preflight`. `install.sh` itself + never runs `sudo`; the preflight it ends with offers what is missing, each + behind a typed `yes` at your terminal. Every download is HTTPS only, + redirects included, at TLS 1.2 or newer. It exits 0 when the command is + installed and the preflight passed, 1 when the command is installed but the + preflight did not pass or the command does not stay on the release tag, and + 2 when it installed nothing. +- **An installed command that stays on its release tag.** With + `OPENREPOSHAPE_RECORD_REF=1`, which `install.sh` sets, `openRepoShape + --install` records `$OPENREPOSHAPE_REF` in `openRepoShape.ref` beside the + command it places. The installed command then fetches `setup.sh` at that + ref, `setup.sh` clones the standard at it (`--shape-ref`), and `--doctor` + checks its clone out at it, and so does a symlink to the command. In + v1.0.0 an installed command fetched main on every run unless + `$OPENREPOSHAPE_REF` was exported. `$OPENREPOSHAPE_REF` set for one run + still overrides the record, and `openRepoShape --version` prints the ref in + force. The installed command is byte for byte the release's own file. ## Install and upgrade @@ -46,20 +48,47 @@ command it installs stays on the release it was installed from (#564). - **By hand, at main:** the README's `gh api repos/opensoft/openRepoShape/contents/openRepoShape … | bash -s -- --install` line still installs main's command, which records no ref and follows main. -- **Windows:** `install.sh` is bash, for macOS, Linux and WSL2. Native - Windows still starts with `py setup-project.py`, as the README says. +- **Platforms:** macOS (the stock `/bin/bash` 3.2 is enough), Linux and WSL2. + Git Bash on Windows is supported too, but the tests of `install.sh` and of + the command skip on the Windows CI job, as the command's always have, so + no CI run exercises Git Bash; that is a known limit, followed up in v1.1.0. + Native Windows without Git Bash still starts with `py setup-project.py`, as + the README says. - **A project's pin** on the standard works as before. Follow [How to pin 1.0.0](v1.0.0.md#how-to-pin-100) with the v1.0.1 release commit. ## A script that runs `--install` -A script that runs `openRepoShape --install` with `$OPENREPOSHAPE_REF` -exported now pins the installed command to that value. If the value is not a -ref the command should run, unset it for the install. To keep the command on a -commit or a tag, set it to that commit or tag. +`openRepoShape --install` records a ref only when it is asked to, with +`OPENREPOSHAPE_RECORD_REF=1`. A script that exports `$OPENREPOSHAPE_REF` +around `--install` for its own reasons gets the install it got from v1.0.0: +nothing is recorded, a record already there is removed, and the command +follows main. To pin the command a script installs, set +`OPENREPOSHAPE_RECORD_REF=1` and set `$OPENREPOSHAPE_REF` to the commit or +tag the command should stay on. ## Known limits v1.0.1 changes nothing else, so its known limits are v1.0.0's: [Known limits in v1.0.0](v1.0.0.md#known-limits). + +## Before tagging (remove at tag time) + + + +The one-line install works only once `install.sh` is an asset of the release +GitHub marks as latest. Nothing in this repository attaches it: + +1. Publish v1.0.1 as the latest release, then attach the file from the + release commit: `gh release upload v1.0.1 install.sh -R opensoft/openRepoShape`. +2. Check the redirect: + `curl -fsSI https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh` + must answer with a `location:` naming the v1.0.1 asset, + `…/releases/download/v1.0.1/install.sh`. +3. Never attach `install.sh` to v1.0.0. Its tree has no `install.sh`, and its + `openRepoShape` cannot record a ref, so the installer would exit 1 over a + command that follows main. +4. Publish a later patch on an older line with `--latest=false` + (`gh release create … --latest=false`), so that `releases/latest`, and + with it the one-line install, keeps naming the newest line. diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index ff065cf7..3bd552a8 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -1184,25 +1184,27 @@ def test_readme_is_short_enough_to_be_read(): # 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. - # 2026-10-14: 1422 -> 1450 — the one-line install, v1.0.1 (#564). Brett + # 2026-10-14: 1422 -> 1444 — the one-line install, v1.0.1 (#564). Brett # Heap: "we need it to be more simple to install ... now and after, we # need a simple install process". Step 3 of the quick start now LEADS # with `curl -fsSL …/releases/latest/download/install.sh | bash`, and - # the worked example's "Install the command" with the same line: - # eleven lines for the two blocks and their lead-ins. Nine say what - # that line does that the old two did not, because each is a thing a - # reader would otherwise meet unannounced on their own machine: it - # reads the newest release from a redirect with no `gh`, it installs - # git-filter-repo with pipx or prints the command, it ends in the - # preflight, it never runs `sudo`, how to name a release, and its three - # exit statuses. Five say what PINNED means — the record beside the - # command, what reads it, and the variable that overrides it — because - # a command that quietly stops following main is a surprise to anyone - # who expected it to. Two re-introduce the existing lines as the - # by-hand way, at main, so neither is lost, and one is the Layout row - # for `install.sh`. - assert len(lines) <= 1450, ( - f"README.md is {len(lines)} lines; the cap is 1450") + # the worked example's "Install the command" with the same line: ten + # lines for the two blocks and the blank lines around them. Nine, step + # 3's paragraph and the blank after it, say what that line does that + # the old two did not, each a thing a reader would otherwise meet + # unannounced on their own machine: it needs no `gh` and no login, the + # command is PINNED to the release tag, where that is recorded and what + # it changes, it installs git-filter-repo with pipx or prints the + # command, it ends in the preflight, `install.sh` itself never runs + # `sudo` while each of the preflight's offers waits for a typed `yes`, + # how to name a release and how to upgrade. Two re-introduce the + # existing lines as the by-hand way, at main, so neither is lost, and + # one is the Layout row for `install.sh`. The review on #577 folded a + # separate "Pinned" paragraph into step 3's and left the exit statuses + # to `install.sh --help` and the release notes, which took the raise + # from 28 lines to 22. + assert len(lines) <= 1444, ( + f"README.md is {len(lines)} lines; the cap is 1444") #: #476: what each document that carries the first rule says an assistant does From 868c9bf886f58cc2901154e1c8c8ce729939c070 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:39:06 +0000 Subject: [PATCH 05/58] docs/release-notes/v1.0.1.md: the "Before tagging" steps are safe to follow in order (land, prove the squash tree or dispatch the three-OS run on it, tag it annotated, create the release with install.sh attached and --verify-tag, check the asset by redirect and by bytes), Git Bash is said to be meant to work but untested until #585, and only install.sh's own downloads are said to be HTTPS only (#564) The second fix round on #577 (RV577). The old checklist published before attaching install.sh, could tag main through `gh release create` with no tag, uploaded an unchecked working-tree file and never tested the release commit. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/v1.0.1.md | 82 +++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 21 deletions(-) diff --git a/docs/release-notes/v1.0.1.md b/docs/release-notes/v1.0.1.md index b165c364..9acb9734 100644 --- a/docs/release-notes/v1.0.1.md +++ b/docs/release-notes/v1.0.1.md @@ -20,8 +20,9 @@ command it installs stays on the release tag it was installed from (#564). `git-filter-repo` is not, and otherwise prints the one command that installs it. It ends with `openRepoShape --preflight`. `install.sh` itself never runs `sudo`; the preflight it ends with offers what is missing, each - behind a typed `yes` at your terminal. Every download is HTTPS only, - redirects included, at TLS 1.2 or newer. It exits 0 when the command is + behind a typed `yes` at your terminal. Every download `install.sh` makes + is HTTPS only, redirects included, at TLS 1.2 or newer. It exits 0 when the + command is installed and the preflight passed, 1 when the command is installed but the preflight did not pass or the command does not stay on the release tag, and 2 when it installed nothing. @@ -49,11 +50,10 @@ command it installs stays on the release tag it was installed from (#564). `gh api repos/opensoft/openRepoShape/contents/openRepoShape … | bash -s -- --install` line still installs main's command, which records no ref and follows main. - **Platforms:** macOS (the stock `/bin/bash` 3.2 is enough), Linux and WSL2. - Git Bash on Windows is supported too, but the tests of `install.sh` and of - the command skip on the Windows CI job, as the command's always have, so - no CI run exercises Git Bash; that is a known limit, followed up in v1.1.0. - Native Windows without Git Bash still starts with `py setup-project.py`, as - the README says. + Git Bash on Windows is meant to work, as #564 asks, but nothing has run it + yet: the launcher's and the installer's tests skip on the Windows job. + #585 (v1.1.0) runs them under Git Bash. Native Windows without Git Bash + still starts with `py setup-project.py`, as the README says. - **A project's pin** on the standard works as before. Follow [How to pin 1.0.0](v1.0.0.md#how-to-pin-100) with the v1.0.1 release commit. @@ -78,17 +78,57 @@ v1.0.1 changes nothing else, so its known limits are v1.0.0's: The one-line install works only once `install.sh` is an asset of the release -GitHub marks as latest. Nothing in this repository attaches it: - -1. Publish v1.0.1 as the latest release, then attach the file from the - release commit: `gh release upload v1.0.1 install.sh -R opensoft/openRepoShape`. -2. Check the redirect: - `curl -fsSI https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh` - must answer with a `location:` naming the v1.0.1 asset, - `…/releases/download/v1.0.1/install.sh`. -3. Never attach `install.sh` to v1.0.0. Its tree has no `install.sh`, and its - `openRepoShape` cannot record a ref, so the installer would exit 1 over a - command that follows main. -4. Publish a later patch on an older line with `--latest=false` - (`gh release create … --latest=false`), so that `releases/latest`, and - with it the one-line install, keeps naming the newest line. +GitHub marks as latest. Nothing in this repository attaches it, so the +release is made in this order, and each step is safe to stop after: + +1. **Land and prove the release commit.** Squash-land #577 into + `release/v1.0`; that squash commit is ``. Confirm that its + tree is the tree of the PR head that passed `tests`, `tests-macos` and + `tests-windows`: + + ```sh + git rev-parse ^{tree} ^{tree} + ``` + + The two are equal when `release/v1.0` had no other commits. If they + differ, run `gh workflow run tests.yml --ref release/v1.0 -R opensoft/openRepoShape` + and go on only when all three jobs pass on ``. A push to + `release/v1.0` runs no CI by itself: `tests.yml` runs on a push to main + only. +2. **Tag that commit, annotated, and push the tag:** + + ```sh + git tag -a v1.0.1 -m "openRepoShape v1.0.1" + git push origin refs/tags/v1.0.1 + ``` + +3. **Create the release with `install.sh` attached, in one command,** from a + checkout at `` and with this section cut from a copy of + these notes: + + ```sh + gh release create v1.0.1 install.sh --verify-tag --latest \ + --title "openRepoShape v1.0.1" --notes-file -R opensoft/openRepoShape + ``` + + `--verify-tag` refuses rather than tag whatever the default branch holds. + gh makes a draft, uploads `install.sh`, then publishes it, so the newest + release is never without the file. +4. **Check the published asset, by redirect and by bytes:** + + ```sh + curl -fsSI --proto '=https' --proto-redir '=https' \ + https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh + curl -fsSL --proto '=https' --proto-redir '=https' \ + https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh \ + | cmp - <(git show v1.0.1:install.sh) + ``` + + The first must answer with a `location:` naming the v1.0.1 asset, + `…/releases/download/v1.0.1/install.sh`; the second, run in bash or zsh, + must print nothing and exit 0. +5. **Never attach `install.sh` to v1.0.0.** Its tree has no `install.sh`, and + its `openRepoShape` cannot record a ref, so the installer would exit 1 over + a command that follows main. **Publish a later patch on an older line with + `--latest=false`**, so that `releases/latest`, and with it the one-line + install, keeps naming the newest line. From b2f0be333abaf7892ead4e4e1a02e7ce0a3a7daa Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:55:06 +0000 Subject: [PATCH 06/58] tests/readme-cap.d/564.txt: README.md's cap is 1449, raised by 22 for the one-line install in a ledger file of its own, as main raises it, rather than #577's inline 1422 -> 1444 on release/v1.0 (#564) README.md on main is 1427 lines at its cap (1422 + 5, #550); #564's lines take it to 1449. The reason is #577's, carried with its accounting: ten lines for the two install.sh blocks, nine for step 3's paragraph, two for the by-hand ways kept at main, one for the Layout row. Co-Authored-By: Claude Opus 5.5 --- tests/readme-cap.d/564.txt | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/readme-cap.d/564.txt diff --git a/tests/readme-cap.d/564.txt b/tests/readme-cap.d/564.txt new file mode 100644 index 00000000..7a500425 --- /dev/null +++ b/tests/readme-cap.d/564.txt @@ -0,0 +1,21 @@ ++22 +2026-10-15 +Twenty-two lines for the one-line install, v1.0.1 (#564), carried into main +for v1.1.0 by Brett Heap's ruling 35. On release/v1.0 #577 raised the cap the +old way, 1422 -> 1444, in the test; here the same lines are this file. +Brett Heap: "we need it to be more simple to install ... now and after, we +need a simple install process". Step 3 of the quick start now LEADS with +`curl -fsSL …/releases/latest/download/install.sh | bash`, and the worked +example's "Install the command" with the same line: ten lines for the two +blocks and the blank lines around them (+10). Nine, step 3's paragraph and the +blank after it, say what that line does that the old two did not, each a thing +a reader would otherwise meet unannounced on their own machine: it needs no +`gh` and no login, the command is PINNED to the release tag, where that is +recorded and what it changes, it installs git-filter-repo with pipx or prints +the command, it ends in the preflight, `install.sh` itself never runs `sudo` +while each of the preflight's offers waits for a typed `yes`, how to name a +release and how to upgrade (+9). Two re-introduce the existing lines as the +by-hand way, at main, so neither is lost (+2), and one is the Layout row for +`install.sh` (+1). The review on #577 folded a separate "Pinned" paragraph +into step 3's and left the exit statuses to `install.sh --help` and the +release notes, which took the raise from 28 lines to 22. From 68b999f0cb3725ca2faf3184af0e6222e08ca709 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:55:48 +0000 Subject: [PATCH 07/58] =?UTF-8?q?docs/handbook.html:=20the=20consent=20rul?= =?UTF-8?q?es=20say=20every=20action=20the=20tool=20OFFERS=20is=20a=20type?= =?UTF-8?q?d=20yes=20and=20the=20one-line=20install=20asks=20nothing,=20pl?= =?UTF-8?q?acing=20the=20command=20and=20git-filter-repo=20through=20pipx?= =?UTF-8?q?=20without=20sudo,=20and=20the=20eight=20sections=20that=20read?= =?UTF-8?q?=20README.md=20=C2=A7=20Starting=20and=20=C2=A7=20Layout=20reco?= =?UTF-8?q?rd=20#564's=20README=20after=20a=20re-read=20(#564)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed in README.md since the markers were set is #564 alone: `git diff f6eaab71 HEAD -- README.md` equals `git diff origin/main HEAD -- README.md` (the § Starting markers were set by #579, f6eaab71), and § Layout moved only by the install.sh row since #551 (5e59e3b3) set its marker. Re-read against the merged README, each section in full: - masthead: its fastest path is #577's install.sh line with an accurate summary (newest release, pinned, git-filter-repo where pipx can, ends in the preflight). No change. - start: carries #577's install step, the by-hand ways at main and "Each way installs". One statement was made false: "Every action that changes the machine or the account is a typed yes" stood a few lines below install.sh installing git-filter-repo with pipx unasked. It now scopes the rule to what the tool offers and says what the one-line install does, every fact quoted from README step 3. The machine: block paragraph, the preflight step, Run it and What setup.sh actually runs stay true as written. - partway (the WARNING), ci (private legs), legs (the --explain line), doublepin (the hand-edited pin), park (openRepoTools' line, which --install still prints when install.sh calls it): nothing #564 touched. No change. - layout: carries #577's install.sh row, in README's order; "standard library only" stays true (install.sh is bash, git-filter-repo a tool). No change. Markers: § Starting f0693d8a -> cc78d254 in masthead, start, partway, ci, legs, doublepin, park; § Layout c8be0af4 -> 94701efc in layout; both values as the hygiene check printed them. Co-Authored-By: Claude Opus 5.5 --- docs/handbook.html | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/handbook.html b/docs/handbook.html index 37c4c26f..5c474cac 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -609,7 +609,7 @@

    The one thing to read before electing it

    - +

    Setup

    Start here

    @@ -961,8 +961,11 @@

    Joining a family

    Consent rules, on every platform

    @@ -610,7 +612,7 @@

    The one thing to read before electing it

    - +

    Setup

    Start here

    @@ -713,7 +715,9 @@

    What this machine needs

    python.exe to PATH — its own checkbox, and a different one from py launcher, which the installer ticks for you. Then git from git-scm.com/downloads, and gh - with winget install --id GitHub.cli -e.

    + with winget install --id GitHub.cli -e. For the commands, pipx: + python -m pip install --user pipx, then python -m pipx ensurepath + and a new terminal.

    The tool offers the rest

    The preflight names anything missing and, for gh, its login and its credential helper, prints the command for THIS machine and asks once — Type yes to continue: — @@ -721,9 +725,10 @@

    The tool offers the rest

    winget install --id GitHub.cli -e for gh and, only under py setup-project.py --preflight, winget install --id Git.Git -e for git: a run refuses a missing git before its preflight starts. - There is no openRepoShape command to install here: that two-liner IS the - Windows way in, and the openRepoShape command is a macOS, Linux and WSL2 - convenience with no Windows twin, because on Windows there is nothing to install. + The openRepoShape command comes from pipx install openreposhape + here, a native openRepoShape.exe beside four tool commands; the two-liner + below is the Windows way in that installs nothing, and the bash command the one-line + install places is for macOS, Linux and WSL2 alone. Printed commands are quoted for a POSIX shell, so paste them into Git Bash. Only shape-doctor.py’s lines, setup-project.py’s family lines and family.py’s NEXT, switch -c, @@ -775,7 +780,7 @@

    Make one machine setting first

  • Install the openRepoShape command, once

    Install the openRepoShape command, once, inside WSL

    -

    Fetch the flow, in the directory the new project should land in

    +

    Install the commands with pipx, or fetch the flow

    curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash

    install.sh is attached to every release and installs the newest, with no gh and no login. Its command is PINNED to the @@ -799,6 +804,14 @@

    Fetch the flow, in the directory the new project should

    Or, where raw downloads are blocked, the same bytes through the API:

    gh api repos/opensoft/openRepoShape/contents/openRepoShape \
         -H 'Accept: application/vnd.github.raw' | bash -s -- --install
    +
    pipx install openreposhape
    +openRepoShape <Project> --org <your-org>
    +

    That places a native openRepoShape.exe with the same + modes as on the other tabs, four commands that run the tools of those names, and + git-filter-repo as their dependency; the second line is then the whole run, + and openRepoShape --preflight the machine check. Or, installing nothing, + fetch the flow into the directory the new project should land in, and the steps below + run it:

    Invoke-WebRequest https://raw.githubusercontent.com/opensoft/openRepoShape/main/setup-project.py -OutFile setup-project.py

    Two commands rather than one pipe, on purpose. Windows PowerShell 5.1 — still the default shell on a stock install — re-encodes piped text as @@ -834,6 +847,19 @@

    Fetch the flow, in the directory the new project should its usage and exits 1 — the four things it does, one line each: install it, check the machine, diagnose a repository, scaffold a project.

    +

    The second way, from PyPI, wherever + pipx is (sudo apt-get install -y pipx on Debian and Ubuntu, + brew install pipx on macOS):

    +
    pipx install openreposhape
    +

    It places an openRepoShape with the same + modes, in Python, whose --install refuses and names pipx upgrade + openreposhape, and four commands that run the tools of those names with every + argument unchanged — scaffold-project, adopt-project, + update-shape, shape-doctor, so docs/cli.md is + their reference — with git-filter-repo 2.47.0 as their dependency. + They run a clone of the standard at the release’s commit, kept in your cache and + checked against its digest before every run. Use one installer or the other: both place + ~/.local/bin/openRepoShape.

  • @@ -1045,7 +1071,7 @@

    What you have afterwards — the same on every platform

    - +

    Setup

    If it fails part-way

    @@ -1080,7 +1106,7 @@

    If it fails part-way

    - + @@ -1138,7 +1164,7 @@

    Reading private legs in CI

    - + @@ -1218,7 +1244,7 @@

    The three legs, and naming

    - +

    Reference

    Adopting an existing repository

    @@ -1233,9 +1259,13 @@

    Adopting an existing repository

    ./adopt-project.py check --plan adoption-plan.yaml ./adopt-project.py execute --plan adoption-plan.yaml --yes
  • git filter-repo is a hard dependency — - pip install git-filter-repo, or the same name from apt or brew. It is - deliberately not vendored: history extraction has one correct implementation, and a copy is a - second one that drifts.

    + pipx install git-filter-repo==2.47.0, else + uv tool install git-filter-repo==2.47.0, else + brew install git-filter-repo or + sudo pacman -S --needed git-filter-repo, in install.sh’s + order; pipx install openreposhape brings it for the + adopt-project it places. It is deliberately not vendored: history extraction has + one correct implementation, and a copy is a second one that drifts.

    Ambiguous is an answer, and no source line is overwritten. contracts/path-classification.yaml maps path globs to spec | code | root | ambiguous, first match wins, each with a reason. The fourth @@ -1374,7 +1404,7 @@

    The InkRouter example

    - + @@ -1671,7 +1701,7 @@

    Is this repository compliant? openRepoShape --doctor

    - + @@ -1826,7 +1856,7 @@

    The degrade rule

    - +

    Reference

    Layout of this repository

    @@ -1841,6 +1871,8 @@

    Layout of this repository

    scripts/validate-repository-naming.py
    answers the naming question for one name
    openRepoShape
    installs itself as a command; runs setup.sh
    install.sh
    the one-line install: a release, pinned
    +
    pyproject.toml
    the openreposhape package: five commands
    +
    src/openreposhape/
    its code, standard library only
    setup.sh
    the shim: find an interpreter, hand over
    setup-project.py
    THE FLOW, and the Windows way in (no bash there)
    scaffold-project.py
    creates the three repositories and the pins
    @@ -1864,8 +1896,9 @@

    Layout of this repository

    scripts/render-cli-reference.py
    renders docs/cli.md; never hand-edit that file
    tests/
    pytest; scaffolds into bare repos in /tmp
    -

    Python 3 standard library only. No pip, no packaging, no - engineering-domain dependency — the shape must run where nothing can be installed. +

    Python 3 standard library only. No tool needs pip, a package + or an engineering-domain dependency — the shape must run where nothing can be installed + — and the openreposhape package is a second way in, never a requirement. tests/test_repo_hygiene.py enforces it. Changes to this repository are tested with python3 -m pytest tests -q, which scaffolds into bare repositories in a temporary directory; never by creating a real GitHub repository.

    @@ -1890,8 +1923,8 @@

    For AI assistants

  • Never answer an install prompt. The preflight asks Type yes to continue: for each thing it offers to run — an install, gh auth login, gh auth setup-git. The human types it; you never - install software or log in for them. The one-line install (install.sh) is - theirs to run: relay it, never run it.
  • + install software or log in for them. The one-line install (install.sh) and + pipx install openreposhape are theirs to run: relay them, never run them.
  • Never pass --allow-upstream-org on your own initiative. If setup.sh refuses because the organisation you were given resolves to opensoft, STOP and relay what it said; the fix is almost always an explicit --org <their-org>.
  • diff --git a/tests/readme-cap.d/563.txt b/tests/readme-cap.d/563.txt new file mode 100644 index 00000000..2b8e9654 --- /dev/null +++ b/tests/readme-cap.d/563.txt @@ -0,0 +1,22 @@ ++28 +2026-10-15 +Twenty-eight lines for the second way in, `pipx install openreposhape` (#563), +which Brett Heap approved as decision 6 of #563's design at +2026-10-15T13:32:51Z ("approve all six for #563"): the one-line install stays +first, pipx comes second, and first on native Windows. +Step 3 of the quick start gains the second way: the command in a block of its +own, where pipx comes from on Debian, Ubuntu and macOS, and what it places +that the one line does not: four commands named for the tools, git-filter-repo +as their dependency, a clone kept at the release's commit and checked against +its digest, how it upgrades, and that both installers place +~/.local/bin/openRepoShape, so a reader uses one of them (+14). +The native Windows block leads with the same command and with how pipx is +installed there, because the one line cannot run on Windows and pipx is the +one install that gives it an `openRepoShape` at all; the two-command download +stays after it, as the way that installs nothing (+9). +git-filter-repo's install under "Adopting" follows install.sh's order, pipx, +then uv, then brew or pacman, where it led with a `pip install` that an +externally managed Python refuses (PEP 668), and says the package brings it +(+2). The Layout block names `pyproject.toml` and `src/openreposhape/`, which +#590 adds (+2), and its standard-library sentence says the package is a second +way in, never a requirement, where it said "no packaging" (+1). From 7692b553d426b77d1d73b45058b65b973e34f965 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 22:43:53 +0000 Subject: [PATCH 38/58] AGENTS.md: `pipx install openreposhape` is the human's to run, as the one-line install is; the installed command may be pipx's, which adds `scaffold-project`, `adopt-project`, `update-shape` and `shape-doctor`, the tools by name; and pipx places it on native Windows too (#563) Decision 6 of #563's design, approved by Brett Heap at 2026-10-15T13:32:51Z, says AGENTS.md gets the one-liner's rule for pipx: the person runs the install, and an agent only relays it. The first rule's sentence now names both installs, reworded to cost no line. Section 2 says the installed `openRepoShape` may be `install.sh`'s or pipx's, that pipx adds the four tool commands by the tools' own names, and that the read-only `machine:` block is the bash command's `--install`; the native Windows paragraph names pipx before the two-liner, which stays as the way that installs nothing. tests/test_repo_hygiene.py: AGENTS.md's cap moves 492 -> 495 for those three lines, argued in the test's history as each raise is. tests/test_agents_md_codes.py: the four command names are allowed as no code a tool prints, with their reason, since the guard reads every code-shaped backticked token as a refusal code. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 21 ++++++++++++--------- tests/test_agents_md_codes.py | 13 +++++++++++++ tests/test_repo_hygiene.py | 18 ++++++++++++++++-- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 23e390bc..9b8b106f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,9 +23,9 @@ hand-edited; `tests/test_cli_reference.py` fails when the two disagree. types `yes` at its prompt; you never answer it for them. No terminal: section 2. **Never answer an install prompt.** The preflight asks `Type yes to continue:` -for each thing it offers to run — an install, `gh auth login`, `gh auth -setup-git`. The human types it; you never install software or log in for them. -The one-line install (`install.sh`) is theirs to run: relay it, never run it. +for each offer — an install, `gh auth login`, `gh auth setup-git` — and the +human types it; you never install software or log in for them. The one-line +`install.sh` and `pipx install openreposhape` are theirs: relay, never run. **Never pass `--allow-upstream-org` on your own initiative.** It guards only `--org opensoft`, the namespace of openRepoShape's upstream owner. If setup.sh @@ -60,13 +60,16 @@ curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoShape/main/setup.s --visibility --elected-by '' ``` -`openRepoShape --org --visibility <…> --elected-by ''` -is the same run through the installed command, still without `--yes`. With no -arguments it prints its usage and exits 1, and `--install` ends with a -read-only `machine:` block naming anything missing; neither installs anything. -Relay the block; the human runs `--preflight` if they want the offers. +`openRepoShape --org --visibility <…> --elected-by ''` is +the same run through the installed command, still without `--yes`, whether +`install.sh` or `pipx install openreposhape` placed it; pipx adds +`scaffold-project`, `adopt-project`, `update-shape` and `shape-doctor`, the +tools below by name. Bare, it prints its usage and exits 1; the bash one's +`--install` ends with a read-only `machine:` block naming anything missing, and +installs nothing: relay it; the human runs `--preflight` for the offers. -On Windows without WSL2, `Invoke-WebRequest https://raw.githubusercontent.com/opensoft/openRepoShape/main/setup-project.py -OutFile setup-project.py` +On Windows without WSL2, `pipx install openreposhape` places that command too. +Installing nothing, `Invoke-WebRequest https://raw.githubusercontent.com/opensoft/openRepoShape/main/setup-project.py -OutFile setup-project.py` then `py setup-project.py --org --visibility <…> --elected-by ''` is the same run again — two commands because a piped script cannot ask — still without `--yes`. diff --git a/tests/test_agents_md_codes.py b/tests/test_agents_md_codes.py index 171db24a..821eed31 100644 --- a/tests/test_agents_md_codes.py +++ b/tests/test_agents_md_codes.py @@ -134,11 +134,24 @@ #: no document names any more, so the list cannot rot into a place where a #: misspelling hides. It may be EMPTY: the day `AGENTS.md` stops citing the #: ruling id below, the entry goes and the dict is `{}`. +#: The four commands the openreposhape package places beside its +#: `openRepoShape` (#563), from `pyproject.toml`'s `[project.scripts]`: +#: AGENTS.md names them as commands an assistant runs, and no tool prints +#: one alone. Spelled once, for the four entries below. +PACKAGE_COMMAND = ( + "a command the openreposhape package places (#563, `pyproject.toml`'s " + "`[project.scripts]`), named so an assistant knows it runs the tool of " + "that name; a command, not a code, and no tool prints it alone") + NOT_A_CODE_A_TOOL_PRINTS = { "prefer-triad-project-shape": ( "an openxFactory ruling id (Brett Heap, 2026-10-06), cited so a " "reader can find the ruling; the tools name it in comments and " "docstrings and print it nowhere"), + "scaffold-project": PACKAGE_COMMAND, + "adopt-project": PACKAGE_COMMAND, + "update-shape": PACKAGE_COMMAND, + "shape-doctor": PACKAGE_COMMAND, } #: Where a failure says to put a name that is no code, so the three messages diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 4d3184bc..20ef1398 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -649,9 +649,23 @@ def test_agents_md_is_short_enough_to_be_read(): question, and `check` passes a default, so an assistant briefed from this file alone would let it through unread. The lines buy that it is a question too, which `execute` takes as written, answered like the - others.""" + others. + + 2026-10-15: 492 -> 495 — the second way in, `pipx install + openreposhape` (#563), decision 6 of #563's design, which Brett Heap + approved that day ("approve all six for #563"). The argument is #38's: + a way in this file does not name is a way in performed from memory. + Two lines are in section 2: the installed command may be pipx's as + well as `install.sh`'s, and pipx places `scaffold-project`, + `adopt-project`, `update-shape` and `shape-doctor` beside it, the + tools this file's procedures run, by name, so an assistant on a + machine that holds the package and no checkout can still follow them. + One is the native Windows line, where pipx now places the command that + the two-liner was the only way to run. The first rule's sentence that + the one-line install is the human's to run now names the pipx install + too, and was reworded to cost no line.""" lines = (REPO / "AGENTS.md").read_text().splitlines() - assert len(lines) <= 492, f"AGENTS.md is {len(lines)} lines; the cap is 492" + assert len(lines) <= 495, f"AGENTS.md is {len(lines)} lines; the cap is 495" def test_claude_md_points_at_agents_md(): From 3df21929ee878ea3ebda815c5b3b88799b315d6e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 22:44:52 +0000 Subject: [PATCH 39/58] docs/cli.md: the checkout group says `pipx install openreposhape` places a Python `openRepoShape` with the same modes, whose `--install` refuses, and `scaffold-project`, `adopt-project`, `update-shape` and `shape-doctor`, each the tool below by name with every argument unchanged (#563) The sentence is the first group's blurb in scripts/render-cli-reference.py, and docs/cli.md is regenerated from it; `render-cli-reference.py --check` says `[ok]`. "The only entry point that needs no checkout at all" was no longer true once the package's four commands exist, so it now says the bash `openRepoShape` needs none, and the package's commands need none either. The flag blocks below are unchanged: the package's commands run those tools with every argument unchanged, so their flags are these. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 2 +- scripts/render-cli-reference.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 3edf01c4..0e002a3f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -53,7 +53,7 @@ one today, and the generator refuses to write this file if one ever appears. ## Run from a checkout of the standard -Every command below is typed at the root of a clone of this repository. `openRepoShape` is also the one file `--install` places on `PATH`, and is the only entry point that needs no checkout at all. +Every command below is typed at the root of a clone of this repository. `openRepoShape` is also the one file `--install` places on `PATH`, and needs no checkout at all. `pipx install openreposhape` (#563) places a Python `openRepoShape` with the same modes, whose own `--help` says what differs (its `--install` refuses and names `pipx upgrade openreposhape`), and four commands that need no checkout either: `scaffold-project`, `adopt-project`, `update-shape` and `shape-doctor` each run the tool of that name below, with every argument unchanged, from a clone of the standard at the package's own release commit, so their flags are the ones below. ### `setup.sh` diff --git a/scripts/render-cli-reference.py b/scripts/render-cli-reference.py index 03cb489d..1243839f 100755 --- a/scripts/render-cli-reference.py +++ b/scripts/render-cli-reference.py @@ -215,7 +215,14 @@ class Tool(NamedTuple): ("Run from a checkout of the standard", CHECKOUT_TOOLS, "Every command below is typed at the root of a clone of this " "repository. `openRepoShape` is also the one file `--install` places on " - "`PATH`, and is the only entry point that needs no checkout at all."), + "`PATH`, and needs no checkout at all. `pipx install openreposhape` " + "(#563) places a Python `openRepoShape` with the same modes, whose own " + "`--help` says what differs (its `--install` refuses and names `pipx " + "upgrade openreposhape`), and four commands that need no checkout " + "either: `scaffold-project`, `adopt-project`, `update-shape` and " + "`shape-doctor` each run the tool of that name below, with every " + "argument unchanged, from a clone of the standard at the package's own " + "release commit, so their flags are the ones below."), ("Shipped into every project (copied, digest-pinned)", PROJECT_TOOLS, "These six are COPIES. `scaffold-project.py` writes them into a " "scaffolded project or a family holder and records each one by sha256 " From 63c1e4bf36755e15561837c85433763abc7e9222 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 22:48:53 +0000 Subject: [PATCH 40/58] docs/release-notes/v1.1.0.md: a first draft of the v1.1.0 notes: openRepoShape on PyPI as `openreposhape`, the one-line install on main, CI on Linux only, every change since v1.0.1 by area, the known limits it adds, and how the release is cut through release.yml, with its placeholders marked (#563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modelled on v1.0.0.md and v1.0.1.md. The changes are the 41 pull requests merged into main after v1.0.0's commit (bfed143), grouped as adopt; update-shape and bump-leg; scaffold, family and the materializer; the doctor; and install, CI and the docs, each with the issues it closes. Known limits carry lane openRepoShape-2's D9 sentence for L2-32 as posted on #473 at 2026-10-15T17:28:24Z, word for word, and three about the package. "How this release is cut" follows release.yml's header (#599): the release commit passes `tests` in a push or dispatch run of tests.yml on that commit, main's push run counting when it finished green and a dispatch on a branch at the commit otherwise. v1.0.1's tree-equality step is not carried over, because release.yml's gate does not accept it. Placeholders, each marked **[placeholder: …]** or ``: the counts at tag time, the pull requests that land after this draft, the known limits re-read against v1.0.0's, lane openRepoShape-5's Git Bash text from #596, the rehearsal run and the date of Brett Heap's acts, and the release commit. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/v1.1.0.md | 366 +++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 docs/release-notes/v1.1.0.md diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md new file mode 100644 index 00000000..90ccddd2 --- /dev/null +++ b/docs/release-notes/v1.1.0.md @@ -0,0 +1,366 @@ +# openRepoShape v1.1.0 + + + +v1.1.0 puts openRepoShape on PyPI. `pipx install openreposhape` is a second +way in, beside the one-line install, and the first on native Windows, where +the one line cannot run (#563). This release also carries v1.0.1's one-line +install into main (#564), fixes limits that v1.0.0 listed, and runs CI on +Linux only (#597). + +**[placeholder: the counts at tag time.]** At drafting time, 41 pull requests +had landed on main since v1.0.0's commit, `bfed143`, and 48 of the `v1.1.0` +milestone's 68 issues were closed. + +## What 1.1.0 adds + +- **openRepoShape on PyPI, as `openreposhape`** (#563). One command installs + it: + + ```sh + pipx install openreposhape + ``` + + It places five commands. `openRepoShape` has the launcher's modes, + ``, `--preflight`, `--doctor`, `--version` and `--help`, written + again in Python, so native Windows gets the command, as an `.exe`. + `scaffold-project`, `adopt-project`, `update-shape` and `shape-doctor` each + run the tool of that name with every argument unchanged, so `docs/cli.md` + is their reference. `git-filter-repo` 2.47.0 comes as their dependency, + and each command puts it first on the `PATH` of the tools it starts. + + The wheel is thin. It carries the release's commit and the + `sorted-ls-tree-r-v1` digest of its tree, and the tools run from a full + clone of this repository at that commit, made once in your per-user cache + and kept. Before each run the clone must be at that commit, unchanged, and + digest to that value, so a project scaffolded through it pins exactly this + release. `$OPENREPOSHAPE_REF` names another ref for one run, with no digest + checked. The package's `--install` refuses and names + `pipx upgrade openreposhape`. Python 3.9 or newer. + + The version is the tag's, so 1.1.0 is the first version on PyPI. The + v1.0.x line is not published there. +- **The one-line install, on main** (#564, carried into main by #587): + + ```sh + curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash + ``` + + It installs the newest release's bash `openRepoShape`, pinned to the + release tag, and ends with `openRepoShape --preflight`, as + [v1.0.1's notes](v1.0.1.md) say. Since v1.0.1: + - it installs git-filter-repo with `uv` where there is no `pipx`, and never + with `pip install --user`, which an externally managed Python refuses + (PEP 668). With neither, it prints one command for this machine: brew's, + pipx's from apt or dnf, pacman's, or uv's (#595); + - it ends with two lines, `adoption:` and then `done:`, so a machine that + is ready to scaffold but not to adopt is told so (#595); + - it refuses a target that is a symbolic link, such as the + `openRepoShape` pipx places, and so does the launcher's `--install` + (#563). +- **CI on Linux only** (#597). Brett Heap decided on 2026-10-15 to drop the + macOS and Windows test jobs across the estate. `tests.yml` runs the whole + suite on `ubuntu-latest`, on every push to main, every pull request and + every dispatch. Since #593 each push run on main is grouped by its own + commit, so the next landing no longer cancels it, and every landing keeps + its own Linux result. The suite no longer exercises Windows file-system + behaviour, and the stock macOS bash 3.2 is a goal the bash entry points + are written for rather than a checked claim. The release workflow still + installs the package on Linux, macOS and Windows before it reaches PyPI. + +## Install and upgrade + +- **The newest release, by the one line:** the line above. The same line + again upgrades. `… | bash -s -- v1.1.0` names this release. + `openRepoShape --version` prints `openRepoShape (opensoft/openRepoShape @ v1.1.0)`. +- **From PyPI:** `pipx install openreposhape`, or + `pipx install openreposhape==1.1.0` for this release. + `pipx upgrade openreposhape` upgrades it. `pipx` itself comes from + `sudo apt-get install -y pipx` on Debian and Ubuntu and + `brew install pipx` on macOS; on Windows, `python -m pip install --user pipx`, + then `python -m pipx ensurepath` and a new terminal. + `openRepoShape --version` prints + `openRepoShape 1.1.0 (opensoft/openRepoShape @ )`, then the + version, tag, commit, digest and the clone it would use. +- **One installer or the other.** Both place `~/.local/bin/openRepoShape`. + pipx does not replace a command it did not place, and `install.sh` and the + launcher's `--install` refuse the link pipx places, naming + `pipx upgrade openreposhape` or removing the link. +- **Native Windows:** `pipx install openreposhape`, then + `openRepoShape --org `. Downloading `setup-project.py` + and running `py setup-project.py --org ` still works, + and installs nothing. **[placeholder: lane openRepoShape-5's sentence for + Git Bash on Windows, from #596 (#585), once it lands.]** +- **A project's pin** on the standard works as before. Follow + [How to pin 1.0.0](v1.0.0.md#how-to-pin-100) with the v1.1.0 release + commit. A scaffold run through the package pins that commit and digest by + itself. + +## What changed since v1.0.1 + +v1.0.1 was cut from `release/v1.0` and added only the one-line install. +Everything below landed on main after v1.0.0's commit, by area. +**[placeholder: the pull requests that land between this draft and the +release commit.]** + +### adopt-project.py + +- **The source's `origin`, read one way.** `plan` reads `/` from + every `origin` git accepts, a local clone or mirror path and a URL ending + `/` included, so a local clone's right `--org` is no longer refused + (#591, closing #521 and #427). +- **Refused before any leg exists.** `check` finds, and `execute` refuses + before any leg exists, a file the root keeps at the shape's own pin path + and a `--work-dir` the user cannot write in. Both used to fail only after + both legs were pushed (#581, #524). `check` recomputes each neutral pin's + digest at the plan's commit, in the clone `plan` read it from, so a commit + or a digest edited into the plan no longer reaches the root as + `adoption verified` over a red `validate-pins.py` (#589, #449). +- **Leg paths that are not the defaults.** The Makefile block `execute` + appends, and the cross-leg follow-up, name each leg where the plan mounts + it, so `--spec-path legs/spec` exports a `CONTRACTS_DIR` that exists and a + nested `--code-path` gets the right `../` path (#572, #522). +- **A source's own `single-repository.yaml`** is asked drop or root, drop by + default, under a rule of its own, and no longer gets the general top-level + YAML question (#566, #528). +- **What a run says is what it does.** The plan's last follow-up is a review + of the pull request `execute` opens itself. A failed verification names + the pull request and the topic it left in place. Under + `--local-remote-dir`, NEXT names the split's branch. AGENTS.md says that + `check` passes an answer with no `resolution:`, and the ruleset hint no + longer offers the seed branch the scaffold dropped (#582, #523). With no + `--elected-by` and no `git config user.name`, the refusal says where the + empty value came from (#567, #529). +- **Symlinks checked out as files.** `check`'s finding for a link kept above + a path the shape writes says that the clone holds it as a file (#584, + #531). +- **Installing git-filter-repo.** `filter-repo-missing` names + `pipx install git-filter-repo==2.47.0`, else + `uv tool install git-filter-repo==2.47.0`, else brew's or pacman's + package, and never `pip install --user`. It says that + `pipx install openreposhape` brings it for the `adopt-project` command it + places (#563). + +### update-shape.py and bump-leg.py + +- **A kept copy that would break bootstrap.** `apply --accept-local` + refuses, before anything is written, to keep a helper that lacks a name a + newly written copy imports from it (#562, #536), or a copy that imports a + name the module written beside it no longer defines (#616, #604). Both + used to pass the validators and then end `make bootstrap` in an + `ImportError`. +- **Hard links and rollback.** `apply` refuses to rewrite a pinned file that + has a second, hard-linked name. A rollback that cannot put a byte back + names that file with the line that finishes the job, and puts back the + rest (#560, #422, #428). +- **Headers, branches and messages.** A `files:` or `shape:` header with a + trailing comment is rewritten and keeps the comment, `--branch` is judged + the same from any directory, and `apply`'s messages say what is true + (#558, #437, #431, #535). +- **`bump-leg.py --dry-run` gives the real run's answer.** `--to` an + annotated tag's own id is peeled to its commit in both runs, and a pin + layout the rewrite refuses is refused by the dry run too (#561, #534, + #438). +- **Paste lines.** `bump-leg.py` and `update-shape.py` quote the roots and + paths in the lines they print, the doctor quotes a leading `=` for zsh, + and the README says the printed commands are for a POSIX shell (#588, + #532). +- **Bootstrap.** A leg detached off its pin is left where it is, and said, + so a commit made there is no longer stranded (#556, #533). A leg on + another branch whose tracking branch is behind the pin is fast-forwarded + and checked out, and the shipped text says bootstrap places a leg where + that loses no commit (#615, #603). +- **The pinned validators.** THE LOCKSTEP RULE is printed only where a leg + or member is out of step. A lone digest or workflow-reference mismatch + names its `bump-leg.py` line, and a holder's remedies name the branch to + make first (#575, #539). + +### scaffold, family and the materializer + +- **Dry runs ask the real run's question.** `scaffold-project.py --dry-run` + and `family.py init --dry-run` ask whether the remotes already exist, so + `scaffold-remote-exists` and `family-remote-exists` come at the plan, and + a missing `gh` is `gh-not-found`, not a traceback (#573, #537). +- **A re-run's legs.** A leg that `gh` sees and git cannot read is refused + `scaffold-remote-exists`, with git's terminal prompt off, and the reuse + re-run's visibility lines go by each leg as it stands (#565, #542). +- **`--pin-source`.** A pin file records `digest_source: local-clone`, never + the path, in the scaffold and in `adopt-project.py`, and a `--pin-source` + nothing reads is refused `pin-source-unused` (#570, #540, #451, #450). +- **setup-project.py.** Its paste lines quote each path, the scaffold's NEXT + STEPS wait for step (7), step (3) refuses a file above the clone, + `-- --dry-run` ends at the plan, step (4) hears the pins passed to it, and + the upstream-organisation guard ignores case (#569, #538). +- **Pin chains.** A bare `--link-source` is the tree of the chain's first + link alone, and a source that names no path is refused (#576, #441, + #442). +- **family.py.** `add` refuses, before the member is read and on every disk, + any path under another spelling of `members/`, with a remedy per entry + that never moves the members' checkouts (#574, #362, #363, #364). `add` + and `remove` refuse a `.gitmodules` the work tree does not hold as the + index does (#571, #407). `bump --to` asks `add`'s duplicate-id question, + peels an annotated tag's own id as `add --at` does, and says to push first + a commit the member does not hold (#568, #541). `make siblings` asks about + the family folder before it clones, clones each member on its tracking + branch, and `family.py siblings` runs the holder's own copy (#578, #543). + +### shape-doctor.py + +- **Both ways in.** A git repository that is not a shape root is told of + the adoption and the scaffold, and the adoption line ends + `--org ` wherever `plan` can read no organisation from `origin` + (#553, #545; #591). +- **What it writes, and its clone.** `openRepoShape --doctor` says it writes + nothing into the repository but a `--placement-plan` file you name, keeps + its clone for the NEXT lines that name it, and refuses a second + `--placement-plan` or a second path (#580, #546). +- **A validator's verdict.** A row's verdict is a printed `FINDING` or + `REFUSED`, and any other non-zero exit is a crash, not a finding about the + project. A crash of the standard's own naming script is `CANNOT ANSWER`, + and a pass that printed `WARNING` or note lines is a `note` that quotes + them (#557, #410, #447, #448). +- **NEXT lines.** The `legs` and `members` NEXT names every detached commit + it would move, and sends a mount with no `.git`, an unborn HEAD or no url + to a line that cures it, or to none (#559, #413, #414). `manifest kinds` + prints one command for its NEXT, and counts a manifest validated only at a + path its gate reads (#555, #548, #439). A conflicted shape pin's NEXT takes + whole each copy both sides hold as the standard wrote it, and a leg pin put + back from before its bump says to commit it and run the `pins` row's line + (#586, #547). The `members` row says `make siblings` places the clones + only from a folder named after the holder's repository (#624, #605). +- **The `machine` row** of a machine that is not ready names the preflight + checks that failed (#552, #549). + +### Install, CI and the docs + +- **PyPI** (#563) and **the one-line install** (#564, #595), above. +- **CI** on Linux only (#597), with a result for every landing on main + (#593). Every test that clones a just-pushed local remote now clones it + in a way git's background maintenance cannot race (#554, #544). +- **The docs.** README.md and the handbook say how to install from PyPI, + first on native Windows, and how to install git-filter-repo without + `pip install --user` (#563). Each handbook section records the hash of + every README section it reads, and README's line cap is a base plus one + file per raise in `tests/readme-cap.d/` (#551, #519). The by-hand setup + commands dry-run the scaffold with the `--visibility` and `--elected-by` + setup passes, and `apply --add`'s five refusals are listed by their codes + (#579, #550, #429). + +## Known limits + +v1.0.0's [known limits](v1.0.0.md#known-limits) that this release does not +close still hold. **[placeholder: re-read every line there against the +closed `v1.1.0` issues and the four tracking parents, #457 to #461, and list +here only what is still open at tag time.]** + +New in v1.1.0: + +- A root scaffolded or adopted with `--pin-source` before v1.1.0 records the source's local path in `digest_source:` in `contracts/-pin.yaml`. Editing it to `local-clone` removes the path from the tip, and git history keeps it. +- A NEXT line pasted as `python3 /adopt-project.py …` runs outside the package, so on a machine whose only git-filter-repo is the package's, it is refused `filter-repo-missing`. The refusal says the package's `adopt-project` has it. +- The package on Windows: CI runs its tests on Linux only (#597). The release's install check runs `--version`, `--preflight`, `--doctor`, the four tools' `--help` and, under PowerShell, `openRepoShape --version` there, and no test scaffolds through it on Windows. +- The README, the handbook and AGENTS.md describe the installed doctor's clone as the bash command's temporary one. The package's `--doctor` runs from its cached clone, which its last line names. +- **[placeholder: Git Bash on Windows, lane openRepoShape-5's text from #596 (#585).]** + +## How this release is cut (remove at tag time) + + + +`.github/workflows/release.yml` publishes the wheel, and its header's +"RELEASING TO PyPI" is the authority for these steps. Nothing is uploaded +until Brett Heap has made the trusted publishers on pypi.org and +test.pypi.org and the `testpypi` and `pypi` environments, and the rehearsal, +a dispatch on a branch, has passed every job it runs. +**[placeholder: the rehearsal's run, and the date of Brett Heap's acts.]** + +1. **The release commit passes `tests` on itself**, in a `push` or + `workflow_dispatch` run of `tests.yml` whose head is that commit. The + build job refuses any other commit, and a pull request's run, or a run on + another commit whose tree is the same, does not count: v1.0.1's + tree-equality check is not this release's gate. Main's push run counts + when it finished green; since #593 the next landing no longer cancels it. + Otherwise, before tagging, dispatch `tests.yml` on a branch that points at + the release commit, and wait for `tests` to pass: + + ```sh + git push origin :refs/heads/ + gh workflow run tests.yml --ref -R opensoft/openRepoShape + ``` + + `--ref main` tests main's current head, not an older commit. If the tag + is pushed already and the gate refused it, dispatch on the tag, + `gh workflow run tests.yml --ref v1.1.0`, wait for it, and re-run the + release run. +2. **Tag that commit, annotated, and push the tag.** The commit must be on + `main`, or on `release/v1.1`: + + ```sh + git tag -a v1.1.0 -m "openRepoShape v1.1.0" + git push origin refs/tags/v1.1.0 + ``` + +3. **Dispatch the release workflow on the tag, with the version:** + + ```sh + gh workflow run release.yml --ref v1.1.0 -f version=1.1.0 \ + -R opensoft/openRepoShape + ``` + +4. **Approve the `testpypi` deployment.** The install check then runs on + ubuntu, macOS and Windows and on Python 3.9. Approve the `pypi` + deployment only once it has passed. A failed job is re-run with "Re-run + failed jobs", never by re-running the whole run or dispatching the + version again. +5. **Only then create the release, with `install.sh` attached,** from a + checkout at ``, with these notes cut down to their + published sections and every placeholder filled: + + ```sh + gh release create v1.1.0 install.sh --verify-tag --latest \ + --title "openRepoShape v1.1.0" --notes-file -R opensoft/openRepoShape + ``` + + so the notes never name a version that PyPI lacks. +6. **Check the published asset, by redirect and by bytes,** as v1.0.1's + step 4 does, with `v1.1.0`: + + ```sh + curl -fsSI --proto '=https' --proto-redir '=https' \ + https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh + curl -fsSL --proto '=https' --proto-redir '=https' \ + https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh \ + | cmp - <(git show v1.1.0:install.sh) + ``` + + The first must answer with a `location:` naming + `…/releases/download/v1.1.0/install.sh`; the second, run in bash or zsh, + must print nothing and exit 0. + +A version is uploaded once. A bad upload is fixed by the next patch, never +by moving a tag. + +## Before tagging (remove at tag time) + + + +- **``** stands for the release commit wherever it appears + above. +- **Not landed at drafting time:** #590 (the package), #599 (the release + workflow) and the docs pull request for #563, which land in that order, + last before the release, because `pipx install openreposhape` works only + once 1.1.0 is on PyPI. +- **Open `v1.1.0` milestone issues at drafting time (20 open, 48 closed):** + - with a pull request open: #418 (#618), #520 (#583), #525 (#617), #526 + (#594), #527 (#614), #530 (#619), #563 (#590, #599 and the docs pull + request), #585 (#596), #600 (#628), #601 (#620), #602 (#621), #606 + (#627), #608 (#623), #609 (#622), #612 (#626), #613 (#625); + - with none: #420, #607, #610, #611. + + Each that lands before the tag gets its line above, and each that does not + is a known limit or moves to the next milestone. + +## Full list + +- Merged pull requests: +- The `v1.1.0` milestone: From 82f9403de3e9c7b3a72a2764a1707983b714bb57 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 22:54:43 +0000 Subject: [PATCH 41/58] docs/release-notes/v1.1.0.md: #625 (#613), which landed on main while the draft was written, is in the family lines, and the drafting-time counts are 42 pull requests and 49 of 68 milestone issues closed (#563) Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/v1.1.0.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index 90ccddd2..d7f73a59 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -10,8 +10,8 @@ the one line cannot run (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At drafting time, 41 pull requests -had landed on main since v1.0.0's commit, `bfed143`, and 48 of the `v1.1.0` +**[placeholder: the counts at tag time.]** At drafting time, 42 pull requests +had landed on main since v1.0.0's commit, `bfed143`, and 49 of the `v1.1.0` milestone's 68 issues were closed. ## What 1.1.0 adds @@ -202,9 +202,12 @@ release commit.]** and `remove` refuse a `.gitmodules` the work tree does not hold as the index does (#571, #407). `bump --to` asks `add`'s duplicate-id question, peels an annotated tag's own id as `add --at` does, and says to push first - a commit the member does not hold (#568, #541). `make siblings` asks about - the family folder before it clones, clones each member on its tracking - branch, and `family.py siblings` runs the holder's own copy (#578, #543). + a commit the member does not hold (#568, #541). It also refuses a commit + that only the member's mount holds, reachable from no branch of the + member's remote, as `bump-leg.py` refuses its twin (#625, #613). + `make siblings` asks about the family folder before it clones, clones each + member on its tracking branch, and `family.py siblings` runs the holder's + own copy (#578, #543). ### shape-doctor.py @@ -350,11 +353,11 @@ by moving a tag. workflow) and the docs pull request for #563, which land in that order, last before the release, because `pipx install openreposhape` works only once 1.1.0 is on PyPI. -- **Open `v1.1.0` milestone issues at drafting time (20 open, 48 closed):** +- **Open `v1.1.0` milestone issues at drafting time (19 open, 49 closed):** - with a pull request open: #418 (#618), #520 (#583), #525 (#617), #526 (#594), #527 (#614), #530 (#619), #563 (#590, #599 and the docs pull request), #585 (#596), #600 (#628), #601 (#620), #602 (#621), #606 - (#627), #608 (#623), #609 (#622), #612 (#626), #613 (#625); + (#627), #608 (#623), #609 (#622), #612 (#626); - with none: #420, #607, #610, #611. Each that lands before the tag gets its line above, and each that does not From 4410da00ad100977ba8d8e8108786625fde6e6cf Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 09:49:51 +0000 Subject: [PATCH 42/58] tests/test_install_sh.py, tests/test_openreposhape_command.py: the two symlink refusal cases spell paths as Git Bash does, so they pass in tests-git-bash (#563, #585) Lane openRepoShape-5's work, applied as lane 5 wrote it: #596 (#585) landed first, so #590, landing second, carries these hunks. In both cases the refusal is matched against `bash_path(link)`, the launcher's case hands `OPENREPOSHAPE_BIN_DIR` over as `bash_path(bin_dir)`, and a link read back has Windows' `\\?\` prefix stripped before it is compared. `bash_path` is #596's, in tests/conftest.py, and returns the path as it is off Windows. Lane 5's evidence: on Windows (probe run 38769166462, the merge of 01e8c2c with this branch) the four cases failed without the hunks and both suites gave 117 passed with them; on Linux, 117 passed. Here, on Linux: the two suites give 117 passed, with and without Path.write_text writing CRLF. Co-Authored-By: Claude Opus 5.5 --- tests/test_install_sh.py | 6 ++++-- tests/test_openreposhape_command.py | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index 28f158b2..2c69f66c 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -671,12 +671,14 @@ def test_a_command_that_is_a_symlink_is_refused_before_any_request( result = net.run() assert result.returncode == 2, result.stdout + result.stderr said = " ".join(result.stderr.split()) - assert f"{link} is a symbolic link, so nothing was installed" in said, said + assert f"{bash_path(link)} is a symbolic link, so nothing was installed" \ + in said, said assert "pipx upgrade openreposhape" in said assert "pipx uninstall openreposhape" in said assert net.calls() == [], net.calls() assert link.is_symlink() - assert link.readlink() == packaged + # Windows reads a link back with `\\?\` ahead of its target. + assert str(link.readlink()).removeprefix("\\\\?\\") == str(packaged) if not dangling: assert packaged.read_bytes() == original assert sorted(path.name for path in net.bin.iterdir()) == \ diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index 35c2dcd4..4f6f2ea6 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -407,17 +407,18 @@ def test_install_refuses_a_target_that_is_a_symlink(tmp_path, dangling): link = bin_dir / "openRepoShape" link.symlink_to(packaged) result = run_cmd("--install", home=tmp_path, - env={"OPENREPOSHAPE_BIN_DIR": str(bin_dir), + env={"OPENREPOSHAPE_BIN_DIR": bash_path(bin_dir), "OPENREPOSHAPE_REF": "v9.9.9", "OPENREPOSHAPE_RECORD_REF": "1"}) assert result.returncode == 2, result.stdout + result.stderr said = " ".join(result.stderr.split()) - assert f"{link} is a symbolic link, so --install will not copy this " \ - "command through it" in said, said + assert f"{bash_path(link)} is a symbolic link, so --install will not " \ + "copy this command through it" in said, said assert "pipx upgrade openreposhape" in said assert "pipx uninstall openreposhape" in said assert link.is_symlink() - assert os.readlink(link) == str(packaged) + # Windows reads a link back with `\\?\` ahead of its target. + assert os.readlink(link).removeprefix("\\\\?\\") == str(packaged) if not dangling: assert packaged.read_bytes() == original assert not packaged.with_name("openRepoShape.ref").exists() From 70955e4dd3861f0f58c146807bac796cd4dba744 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 09:54:00 +0000 Subject: [PATCH 43/58] .github/workflows/tests.yml: the header says what is the `package` job's own, that what it checks needs PyPI, not that it alone reaches PyPI (#563) Comment only, three lines for three. `tests` and `tests-git-bash` both pip-install pytest 8.3.4, and `tests` git-filter-repo too, so "the one job that reaches PyPI" was not true. What is the package job's own is that it builds the wheel and installs it with pipx, which needs the build backend and git-filter-repo from PyPI. The job's own comment below says only that those two come from PyPI and that the suite in `tests` never touches the network, which holds, and is unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 266c62c7..7928afaf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,9 +12,9 @@ name: tests # below runs the launcher's and the installer's suites, and nothing else, on # Windows under Git Bash. # -# The `package` job at the end is the one job that reaches PyPI, and it runs -# no pytest: building the openreposhape wheel and installing it with pipx -# need the build backend and git-filter-repo from there (#563). +# The `package` job at the end runs no pytest. It builds the openreposhape +# wheel and installs it with pipx, so what it checks needs PyPI (the build +# backend, git-filter-repo; #563). The other jobs take only test tools there. on: push: From b3479431ba4d7b7252d7184106a6ed729efd35b3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 09:58:25 +0000 Subject: [PATCH 44/58] adopt-project.py: `filter-repo-missing` names git-filter-repo's install in install.sh's whole order, with pipx's route from apt or dnf after brew, and says nothing of `pip install --user`, which the Windows text tells readers to use for pipx (RV630 SHOULD 1, #563) RV630 found the hint was not install.sh's order: `filter_repo_command` prints brew's, then `sudo apt-get install -y pipx && pipx install git-filter-repo==2.47.0 && pipx ensurepath` (dnf on Fedora), then pacman's, and the hint skipped the apt and dnf route that Debian, Ubuntu and Fedora get. It now names all of them, after the pipx or uv already there. "Never `pip install --user`" is gone from the printed hint: the README and the handbook tell a native Windows reader `python -m pip install --user pipx`, and PEP 668 refuses it only for a distribution's own Python, which the comment above the constant now says. Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index dcc0588a..20c72cb2 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -847,21 +847,22 @@ def _pin_digest_findings(plan: "Plan") -> tuple[list[str], list[str]]: SEED_TEMPLATE = {"spec": "templates/spec-root", "code": "templates/code-root"} EXTRACTED_LEGS = ("spec", "code") -#: In install.sh's order (#595): pipx, else the uv already there, else the -#: platform's own package. Never `pip install --user`, which Debian 12, -#: Ubuntu 23.04 and Arch refuse for their system Python (PEP 668). The -#: openreposhape package depends on git-filter-repo and puts it on PATH for -#: the `adopt-project` it places, but not for this file run by its path -#: (#563). +#: In install.sh's order (#595): with the pipx or uv already there, else +#: brew's, else pipx from apt or dnf, else Arch's own package. No `pip +#: install --user`: Debian 12, Ubuntu 23.04 and Arch mark their own Python +#: externally managed (PEP 668) and refuse it. The openreposhape package +#: depends on git-filter-repo and puts it on PATH for the `adopt-project` it +#: places, but not for this file run by its path (#563). FILTER_REPO_HINT = ( "Remediation: install it — `pipx install git-filter-repo==2.47.0`, or " - "`uv tool install git-filter-repo==2.47.0` where there is no pipx, or the " - "platform's own package (`brew install git-filter-repo`, `sudo pacman -S " - "--needed git-filter-repo`), never `pip install --user`; it is one file " - "on PATH named `git-filter-repo`. `pipx install openreposhape` brings it " - "for the `adopt-project` command it places. It is NOT vendored here on " - "purpose: history extraction has one correct implementation and a copy of " - "it is a second one that drifts." + "`uv tool install git-filter-repo==2.47.0` where there is no pipx; with " + "neither, `brew install git-filter-repo`, or `sudo apt-get install -y " + "pipx && pipx install git-filter-repo==2.47.0 && pipx ensurepath` (`dnf` " + "for `apt-get` on Fedora), or `sudo pacman -S --needed git-filter-repo` " + "on Arch; it is one file on PATH named `git-filter-repo`. `pipx install " + "openreposhape` brings it for the `adopt-project` command it places. It " + "is NOT vendored here on purpose: history extraction has one correct " + "implementation and a copy of it is a second one that drifts." ) From c3e9a3d43aceefe13219cfcf45c6ac71367b8b78 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 09:58:25 +0000 Subject: [PATCH 45/58] README.md and the handbook: #563's raise is +12, not +28, with RV630's shorter Windows block and step 3, a filter-repo paragraph that defers to the one line, one Layout row and a three-line standard-library sentence; the bash command's exit 2 and the doctor's kept clone are said true for both commands (RV630 SHOULD 1-4, nits 3-6, 9) README.md (1477 -> 1461 lines, cap 1449 + 12): - native Windows block (A2): `pipx install openreposhape` inline, with how to get pipx, and the two-liner after it as the way that installs nothing (10 lines -> 4); - step 3's second way (B2): one paragraph, with `pipx ensurepath` (nit 9) and "its `--install` refuses" in place of "the same modes" (nit 3) (13 lines -> 7); - Adopting (C): step 3's one line installs git-filter-repo or prints the command for this machine, and the package brings it; no install order to drift from install.sh's (SHOULD 1) (6 -> 4); - the preflight's exit 2 is the bash `openRepoShape --preflight`'s; the package's exits 1 when git is missing (SHOULD 4), zero lines; - the doctor KEEPS "its clone", not "its temporary clone": the package's is its cached one (SHOULD 3), zero lines; - Layout: one `src/openreposhape/` row naming pyproject.toml (D), and `setup-project.py` is "a Windows way in from PowerShell" (nit 6); - the standard-library sentence in three lines (E). docs/handbook.html: the same facts, in its own words: the Adopting paragraph, the kept clone, the bash command's exit 2 on the Linux, macOS and WSL2 tabs, the Layout rows and the standard-library sentence; on the Windows panel, pipx is one more manual install and pipx's `openRepoShape --preflight` runs the same winget offers (nit 4); on the Windows tab's check, rehearsal and run steps, a line for pipx's command (nit 5); step 1's second way names `pipx ensurepath`. Markers. Text and marker: Starting a project in a new organisation (7), Adopting an existing repository (1), Keeping a project's shape current (2), Layout of this repository (1). No other section's marker moves. tests/readme-cap.d/563.txt: +28 -> +12, with the new reasons. Co-Authored-By: Claude Opus 5.5 --- README.md | 60 ++++++++++--------------- docs/handbook.html | 90 ++++++++++++++++++++------------------ tests/readme-cap.d/563.txt | 35 ++++++++------- 3 files changed, 87 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 7780030d..bbce2fd1 100644 --- a/README.md +++ b/README.md @@ -129,16 +129,10 @@ its own `contracts/shape-pin.yaml`, so a CRLF checkout reports drift in files nobody edited. The clone `setup-project.py` makes is configured correctly whatever the machine says; the global setting is for every clone after it. -Then the commands, from PyPI, with `pipx` (where it is missing: `python -m pip +Then `pipx install openreposhape` (where `pipx` is missing: `python -m pip install --user pipx`, then `python -m pipx ensurepath` and a new terminal): - -```powershell -pipx install openreposhape -openRepoShape --org -``` - -Step 3 below says what that places. Or, installing nothing, two commands, run -in the directory the new project should land in: +step 3 says what it places, and step 4 runs it. Or, installing nothing, two +commands, run in the directory the new project should land in: ```powershell Invoke-WebRequest https://raw.githubusercontent.com/opensoft/openRepoShape/main/setup-project.py -OutFile setup-project.py @@ -189,8 +183,8 @@ run the four steps below INSIDE the WSL shell. > `brew` the preflight names and stops there. To check a > machine and stop, creating nothing, run `openRepoShape --preflight` — > `py setup-project.py --preflight` on Windows — which exits 0 when everything -> is present and 1 when it is not (`openRepoShape --preflight` exits 2 when -> `git` or a Python 3.9 or newer is missing). +> is present and 1 when it is not (the bash `openRepoShape --preflight` exits +> 2 when `git` or a Python 3.9 or newer is missing). **1. Install the GitHub CLI**, `gh`. The official instructions are at (and ): @@ -284,19 +278,13 @@ things it does, one line each: install it, check the machine scaffold a project. Type any scaffold argument and you get the run you asked for, refusals and all. -**The second way, from PyPI,** wherever `pipx` is (`sudo apt-get install -y -pipx` on Debian and Ubuntu, `brew install pipx` on macOS): - -```sh -pipx install openreposhape -``` - -It places an `openRepoShape` with the same modes and four commands that run the -tools of those names, `scaffold-project`, `adopt-project`, `update-shape` and -`shape-doctor`, with `git-filter-repo` 2.47.0 as their dependency. They run a -clone of the standard at the release's commit, kept in your cache and checked -against its digest before every run. `pipx upgrade openreposhape` upgrades -them. Use one installer or the other: both place `~/.local/bin/openRepoShape`. +**The second way, from PyPI:** `pipx install openreposhape`, wherever `pipx` +is (`sudo apt-get install -y pipx` on Debian and Ubuntu, `brew install pipx` on +macOS, then `pipx ensurepath`). It places the same `openRepoShape` (its +`--install` refuses) and `scaffold-project`, `adopt-project`, `update-shape` +and `shape-doctor`, with `git-filter-repo` 2.47.0; each runs a cached clone at +the release's commit, digest-checked every run. `pipx upgrade openreposhape` +upgrades. Use one installer, not both: each places `~/.local/bin/openRepoShape`. **4. Run it:** @@ -788,11 +776,9 @@ only, and a tool needing a bypass cannot be used where it is needed. ./adopt-project.py execute --plan adoption-plan.yaml --yes ``` -`git filter-repo` is a **hard dependency** — `pipx install -git-filter-repo==2.47.0`, else `uv tool install git-filter-repo==2.47.0`, else -`brew install git-filter-repo` or `sudo pacman -S --needed git-filter-repo`, as -`install.sh` does it; `pipx install openreposhape` brings it for the -`adopt-project` it places. It is deliberately not vendored: history extraction +`git filter-repo` is a **hard dependency**: step 3's one line installs it or +prints the command for this machine, and `pipx install openreposhape` brings it +for its `adopt-project`. It is deliberately not vendored: history extraction has one correct implementation, and a copy is a second one that drifts. ### The worked example: MedxEHR — 167 files, 25 commits, three arguments @@ -1163,8 +1149,8 @@ openRepoShape --doctor # the repository you are standing in It **writes nothing into the repository but a `--placement-plan` file you name, and fetches nothing from it**, and it reimplements nothing: each row is a check this file documents. It compares against the checkout it is RUN FROM, so the -verdict is offline. Installed, it KEEPS its temporary clone of the standard for -the NEXT lines naming it: its last line names it, safe to delete after they run. +verdict is offline. Installed, it KEEPS its clone of the standard for the NEXT +lines naming it: its last line names it, safe to delete after they run. For an assembly root the rows are **naming** (this standard's own `contracts/repository-naming.yaml`), **manifest** and **pins** (the project's @@ -1441,10 +1427,9 @@ scripts/shape_materialize.py the ONE materializer, used by both tools scripts/validate-repository-naming.py openRepoShape installs itself as a command; runs setup.sh install.sh the one-line install: a release, pinned -pyproject.toml the openreposhape package: five commands -src/openreposhape/ its code, standard library only +src/openreposhape/ the pipx package (pyproject.toml); stdlib only setup.sh the shim: find an interpreter, hand over -setup-project.py THE FLOW, and the Windows way in (no bash there) +setup-project.py THE FLOW, and a Windows way in from PowerShell scaffold-project.py creates the three repositories and the pins adopt-project.py converts an EXISTING repository in place update-shape.py re-syncs a root's copies and re-pins @@ -1467,10 +1452,9 @@ scripts/render-cli-reference.py renders docs/cli.md; never hand-edit that file tests/ pytest; scaffolds into bare repos in /tmp ``` -**Python 3 standard library only.** No tool needs `pip`, a package or an -engineering-domain dependency — the shape must run where nothing can be -installed — and the `openreposhape` package is a second way in, never a -requirement. `tests/test_repo_hygiene.py` enforces it. +**Python 3 standard library only.** No tool needs `pip` or any dependency, so +the shape runs where nothing can be installed; the `openreposhape` package is a +second way in, never a requirement. `tests/test_repo_hygiene.py` enforces it. ## Licence diff --git a/docs/handbook.html b/docs/handbook.html index 303c1dbb..c1c09931 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -612,7 +612,7 @@

    The one thing to read before electing it

    - +

    Setup

    Start here

    @@ -710,21 +710,23 @@

    The tool offers the rest

    What this machine needs

    -

    Python is the one manual install. Install Python 3.9 or newer from +

    Python is the one manual install the flow needs. Install Python 3.9 or newer from python.org/downloads, ticking Add python.exe to PATH — its own checkbox, and a different one from py launcher, which the installer ticks for you. Then git from git-scm.com/downloads, and gh - with winget install --id GitHub.cli -e. For the commands, pipx: - python -m pip install --user pipx, then python -m pipx ensurepath - and a new terminal.

    + with winget install --id GitHub.cli -e. The commands need one more manual + install, pipx: python -m pip install --user pipx, then + python -m pipx ensurepath and a new terminal.

    The tool offers the rest

    The preflight names anything missing and, for gh, its login and its credential helper, prints the command for THIS machine and asks once — Type yes to continue: — then runs it and carries on. On Windows those offers are the winget ones: winget install --id GitHub.cli -e for gh and, only under - py setup-project.py --preflight, winget install --id Git.Git -e - for git: a run refuses a missing git before its preflight starts. + py setup-project.py --preflight or pipx’s + openRepoShape --preflight, which runs the same preflight, + winget install --id Git.Git -e for git: a run refuses a missing + git before its preflight starts. In PowerShell the openRepoShape command comes from pipx install openreposhape, a native openRepoShape.exe beside four tool commands, and the two-liner below is the way in that installs nothing. Git @@ -852,7 +854,7 @@

    Install the commands with pipx, or fetch the flow

    project.

    The second way, from PyPI, wherever pipx is (sudo apt-get install -y pipx on Debian and Ubuntu, - brew install pipx on macOS):

    + brew install pipx on macOS, then pipx ensurepath):

    pipx install openreposhape

    It places an openRepoShape with the same modes, in Python, whose --install refuses and names pipx upgrade @@ -870,17 +872,19 @@

    Check the machine, creating nothing

    openRepoShape --preflight
    py setup-project.py --preflight

    It checks this machine, offers what is missing and creates nothing. It exits 0 when - everything is present and 1 when it is not; it exits 2 instead, before any check, when - git or a Python 3.9 or newer is missing.

    -

    It exits 0 when everything is present and 1 when it is not, and creates nothing; it - exits 2 instead, before any check, when git or a Python 3.9 or newer is - missing. This is also where a python3 below 3.9 is named.

    + everything is present and 1 when it is not; the bash command exits 2 instead, before any + check, when git or a Python 3.9 or newer is missing.

    +

    It exits 0 when everything is present and 1 when it is not, and creates nothing; the + bash command exits 2 instead, before any check, when git or a Python 3.9 or + newer is missing. This is also where a python3 below 3.9 is named.

    It exits 0 when everything is present and 1 when it is not. python setup-project.py --preflight is the same command on a machine with no py: Python from the Microsoft Store installs python and no - launcher at all.

    -

    It exits 0 when everything is present and 1 when it is not; it exits 2 - instead, before any check, when git or a Python 3.9 or newer is missing.

    + launcher at all. With pipx’s command, openRepoShape --preflight runs + the same check.

    +

    It exits 0 when everything is present and 1 when it is not; the bash command + exits 2 instead, before any check, when git or a Python 3.9 or newer is + missing.

  • @@ -906,6 +910,8 @@

    Rehearse first, creating nothing

    scripts/bootstrap.py — and gh is never called. On Windows the same flag goes on the Python entry point:

    py setup-project.py Atlas --org <your-org> --local-remote-dir .\rehearsal
    +

    With pipx’s command, the same flag on + openRepoShape Atlas --org <your-org>.

    openRepoShape Atlas --org <your-org> --local-remote-dir ./rehearsal

    --local-remote-dir <dir> runs the whole of the real thing against three BARE repositories in <dir>: the same preflight (where @@ -965,7 +971,9 @@

    Run it

    and asks once, Type yes to continue:; type yes. Afterwards ./<Project> is the cloned root.

    python setup-project.py <Project> --org <your-org> is the - same command on a machine with no py. setup-project.py is the + same command on a machine with no py, and pipx’s + openRepoShape <Project> --org <your-org> the same run again. + setup-project.py is the flow itself, and setup.sh a shim over it: the same preflight, the same naming check, the same plan, the same one yes, the same self-bootstrap outside a checkout. It never needs make and never looks for a @@ -1075,7 +1083,7 @@

    What you have afterwards — the same on every platform

    - +

    Setup

    If it fails part-way

    @@ -1110,7 +1118,7 @@

    If it fails part-way

    - + @@ -1168,7 +1176,7 @@

    Reading private legs in CI

    - + @@ -1248,7 +1256,7 @@

    The three legs, and naming

    - +

    Reference

    Adopting an existing repository

    @@ -1262,14 +1270,11 @@

    Adopting an existing repository

    # ... a human or an AI answers every `review_required` entry in the plan ./adopt-project.py check --plan adoption-plan.yaml ./adopt-project.py execute --plan adoption-plan.yaml --yes
    -

    git filter-repo is a hard dependency — - pipx install git-filter-repo==2.47.0, else - uv tool install git-filter-repo==2.47.0, else - brew install git-filter-repo or - sudo pacman -S --needed git-filter-repo, in install.sh’s - order; pipx install openreposhape brings it for the - adopt-project it places. It is deliberately not vendored: history extraction has - one correct implementation, and a copy is a second one that drifts.

    +

    git filter-repo is a hard dependency: the one-line install + installs it or prints the command for this machine, and + pipx install openreposhape brings it for its adopt-project. It is + deliberately not vendored: history extraction has one correct implementation, and a copy is + a second one that drifts.

    Ambiguous is an answer, and no source line is overwritten. contracts/path-classification.yaml maps path globs to spec | code | root | ambiguous, first match wins, each with a reason. The fourth @@ -1408,7 +1413,7 @@

    The InkRouter example

    - + @@ -1508,7 +1513,7 @@

    Bootstrap is COPIED into the project, not fetched

    - +

    Reference

    Keeping a project’s shape current

    @@ -1570,7 +1575,7 @@

    Keeping a project’s shape current

    - +

    Reference

    Is this repository compliant? openRepoShape --doctor

    @@ -1585,8 +1590,8 @@

    Is this repository compliant? openRepoShape --doctor

    the one that writes at all, and it writes one file, at the path you give: a relative one lands in the directory you stand in, inside the repository when you stand there. It compares against the checkout it is RUN FROM, so the verdict is offline. Installed, it - clones the standard into a temporary directory and KEEPS it for the NEXT lines that name - it: its last line names it, safe to delete after they run.

    + clones the standard and KEEPS the clone for the NEXT lines that name it: its last line + names it, safe to delete after they run.

    For an assembly root the rows are naming, manifest and pins (the project’s OWN validators when it carries them — they are the pinned copies, which is the point — else the template copies, and the row says @@ -1705,7 +1710,7 @@

    Is this repository compliant? openRepoShape --doctor

    - + @@ -1860,7 +1865,7 @@

    The degrade rule

    - +

    Reference

    Layout of this repository

    @@ -1875,10 +1880,9 @@

    Layout of this repository

    scripts/validate-repository-naming.py
    answers the naming question for one name
    openRepoShape
    installs itself as a command; runs setup.sh
    install.sh
    the one-line install: a release, pinned
    -
    pyproject.toml
    the openreposhape package: five commands
    -
    src/openreposhape/
    its code, standard library only
    +
    src/openreposhape/
    the pipx package (pyproject.toml); stdlib only
    setup.sh
    the shim: find an interpreter, hand over
    -
    setup-project.py
    THE FLOW, and the Windows way in (no bash there)
    +
    setup-project.py
    THE FLOW, and a Windows way in from PowerShell
    scaffold-project.py
    creates the three repositories and the pins
    adopt-project.py
    converts an EXISTING repository in place
    update-shape.py
    re-syncs a root’s copies and re-pins
    @@ -1900,9 +1904,9 @@

    Layout of this repository

    scripts/render-cli-reference.py
    renders docs/cli.md; never hand-edit that file
    tests/
    pytest; scaffolds into bare repos in /tmp
    -

    Python 3 standard library only. No tool needs pip, a package - or an engineering-domain dependency — the shape must run where nothing can be installed - — and the openreposhape package is a second way in, never a requirement. +

    Python 3 standard library only. No tool needs pip or any + dependency, so the shape runs where nothing can be installed; the + openreposhape package is a second way in, never a requirement. tests/test_repo_hygiene.py enforces it. Changes to this repository are tested with python3 -m pytest tests -q, which scaffolds into bare repositories in a temporary directory; never by creating a real GitHub repository.

    diff --git a/tests/readme-cap.d/563.txt b/tests/readme-cap.d/563.txt index 2b8e9654..1b86e38d 100644 --- a/tests/readme-cap.d/563.txt +++ b/tests/readme-cap.d/563.txt @@ -1,22 +1,23 @@ -+28 ++12 2026-10-15 -Twenty-eight lines for the second way in, `pipx install openreposhape` (#563), +Twelve lines for the second way in, `pipx install openreposhape` (#563), which Brett Heap approved as decision 6 of #563's design at 2026-10-15T13:32:51Z ("approve all six for #563"): the one-line install stays first, pipx comes second, and first on native Windows. -Step 3 of the quick start gains the second way: the command in a block of its -own, where pipx comes from on Debian, Ubuntu and macOS, and what it places -that the one line does not: four commands named for the tools, git-filter-repo -as their dependency, a clone kept at the release's commit and checked against -its digest, how it upgrades, and that both installers place -~/.local/bin/openRepoShape, so a reader uses one of them (+14). +Step 3 of the quick start gains the second way, one paragraph: the command, +where pipx comes from on Debian, Ubuntu and macOS, and what it places that the +one line does not: four commands named for the tools, git-filter-repo as their +dependency, a cached clone at the release's commit checked against its digest, +how it upgrades, and that both installers place ~/.local/bin/openRepoShape, so +a reader uses one of them (+8). The native Windows block leads with the same command and with how pipx is -installed there, because the one line cannot run on Windows and pipx is the -one install that gives it an `openRepoShape` at all; the two-command download -stays after it, as the way that installs nothing (+9). -git-filter-repo's install under "Adopting" follows install.sh's order, pipx, -then uv, then brew or pacman, where it led with a `pip install` that an -externally managed Python refuses (PEP 668), and says the package brings it -(+2). The Layout block names `pyproject.toml` and `src/openreposhape/`, which -#590 adds (+2), and its standard-library sentence says the package is a second -way in, never a requirement, where it said "no packaging" (+1). +installed there, because pipx is the one install that gives PowerShell an +`openRepoShape`; the two-command download stays after it, as the way that +installs nothing (+3). +The Layout block names `src/openreposhape/`, the package #590 adds (+1). +Reworded at no cost in lines: git-filter-repo under "Adopting" says step 3's +one line installs it or prints the command for this machine, where it led with +a `pip install` that an externally managed Python refuses (PEP 668), and that +the package brings it; the standard-library sentence says the package is a +second way in, never a requirement, where it said "no packaging"; the native +Windows paragraph keeps #596's Git Bash sentence beside the pipx one. From 0470497100241b2164300ae6897af79499e55155 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 09:58:25 +0000 Subject: [PATCH 46/58] AGENTS.md: section 2's installed-command paragraph says native Windows gets pipx's command too, so the Windows line is main's again and the cap is 494, not 495; the doctor KEEPS "its clone"; and the allowlist's comment block sits over the name it documents (RV630 SHOULD 2-3, nit 1) - AGENTS.md (495 -> 494, main 492): RV630's AG2 text for the paragraph after section 2's one-liner, which adds "(native Windows too)" and drops "for the offers"; the native Windows line is main's `On Windows without WSL2, Invoke-WebRequest ...` again; "KEEPS its temporary clone" loses "temporary", at zero lines. - tests/test_repo_hygiene.py: the AGENTS.md cap is 494, and the 2026-10-15 entry of its history says why two lines, not three. - tests/test_agents_md_codes.py: `PACKAGE_COMMAND` and its `#:` block come first, then the block that documents `NOT_A_CODE_A_TOOL_PRINTS` right above it; "It may be EMPTY" now says an entry goes when AGENTS.md stops naming it, the ruling id or a package command, and the dict is `{}` once none is left. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 17 ++++++++--------- tests/test_agents_md_codes.py | 15 ++++++++------- tests/test_repo_hygiene.py | 21 ++++++++++----------- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2d06a930..778af8eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,14 +62,13 @@ curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoShape/main/setup.s `openRepoShape --org --visibility <…> --elected-by ''` is the same run through the installed command, still without `--yes`, whether -`install.sh` or `pipx install openreposhape` placed it; pipx adds -`scaffold-project`, `adopt-project`, `update-shape` and `shape-doctor`, the -tools below by name. Bare, it prints its usage and exits 1; the bash one's -`--install` ends with a read-only `machine:` block naming anything missing, and -installs nothing: relay it; the human runs `--preflight` for the offers. - -On Windows without WSL2, `pipx install openreposhape` places that command too. -Installing nothing, `Invoke-WebRequest https://raw.githubusercontent.com/opensoft/openRepoShape/main/setup-project.py -OutFile setup-project.py` +`install.sh` or `pipx install openreposhape` (native Windows too) placed it; +pipx adds `scaffold-project`, `adopt-project`, `update-shape` and +`shape-doctor`, the tools below by name. Bare, it prints its usage and exits 1; +the bash one's `--install` ends with a read-only `machine:` block naming +anything missing, and installs nothing: relay it; the human runs `--preflight`. + +On Windows without WSL2, `Invoke-WebRequest https://raw.githubusercontent.com/opensoft/openRepoShape/main/setup-project.py -OutFile setup-project.py` then `py setup-project.py --org --visibility <…> --elected-by ''` is the same run again — two commands because a piped script cannot ask — still without `--yes`. @@ -220,7 +219,7 @@ no `--dry-run` to do first. The one exception to "writes nothing" is The checks are a REGISTRY with an empty `fix` slot in each of them, which is where a repair mode will hang; nothing in it writes today. Installed, `openRepoShape --doctor` takes `--json` and `--placement-plan ` as well, -and KEEPS its temporary clone of the standard for the NEXT lines that name it: +and KEEPS its clone of the standard for the NEXT lines that name it: its last line names that directory, the human's to delete once those have run. 1. **Read the verdict, then read the rows.** `COMPLIANT`, `COMPLIANT, SHAPE diff --git a/tests/test_agents_md_codes.py b/tests/test_agents_md_codes.py index 821eed31..915da971 100644 --- a/tests/test_agents_md_codes.py +++ b/tests/test_agents_md_codes.py @@ -128,21 +128,22 @@ #: `-wip` are not names and never need an entry below. CODE_SHAPE = re.compile(r"[a-z]+(-[a-z0-9]+)+") -#: A code-shaped name `AGENTS.md` spells that is NOT a code a tool prints, -#: each with its reason. Verdict words a tool prints are checked, never -#: listed here. The test fails an entry that a tool does print and an entry -#: no document names any more, so the list cannot rot into a place where a -#: misspelling hides. It may be EMPTY: the day `AGENTS.md` stops citing the -#: ruling id below, the entry goes and the dict is `{}`. #: The four commands the openreposhape package places beside its #: `openRepoShape` (#563), from `pyproject.toml`'s `[project.scripts]`: #: AGENTS.md names them as commands an assistant runs, and no tool prints -#: one alone. Spelled once, for the four entries below. +#: one alone. The reason, spelled once, for their four entries below. PACKAGE_COMMAND = ( "a command the openreposhape package places (#563, `pyproject.toml`'s " "`[project.scripts]`), named so an assistant knows it runs the tool of " "that name; a command, not a code, and no tool prints it alone") +#: A code-shaped name `AGENTS.md` spells that is NOT a code a tool prints, +#: each with its reason. Verdict words a tool prints are checked, never +#: listed here. The test fails an entry that a tool does print and an entry +#: no document names any more, so the list cannot rot into a place where a +#: misspelling hides. It may be EMPTY: an entry goes the day `AGENTS.md` +#: stops naming it (the ruling id, or one of the package's commands), and +#: once none is left the dict is `{}`. NOT_A_CODE_A_TOOL_PRINTS = { "prefer-triad-project-shape": ( "an openxFactory ruling id (Brett Heap, 2026-10-06), cited so a " diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 20ef1398..91b2f5eb 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -651,21 +651,20 @@ def test_agents_md_is_short_enough_to_be_read(): a question too, which `execute` takes as written, answered like the others. - 2026-10-15: 492 -> 495 — the second way in, `pipx install + 2026-10-15: 492 -> 494 — the second way in, `pipx install openreposhape` (#563), decision 6 of #563's design, which Brett Heap approved that day ("approve all six for #563"). The argument is #38's: a way in this file does not name is a way in performed from memory. - Two lines are in section 2: the installed command may be pipx's as - well as `install.sh`'s, and pipx places `scaffold-project`, - `adopt-project`, `update-shape` and `shape-doctor` beside it, the - tools this file's procedures run, by name, so an assistant on a - machine that holds the package and no checkout can still follow them. - One is the native Windows line, where pipx now places the command that - the two-liner was the only way to run. The first rule's sentence that - the one-line install is the human's to run now names the pipx install - too, and was reworded to cost no line.""" + Both lines are in section 2: the installed command may be pipx's as + well as `install.sh`'s, on native Windows too, and pipx places + `scaffold-project`, `adopt-project`, `update-shape` and `shape-doctor` + beside it, the tools this file's procedures run, by name, so an + assistant on a machine that holds the package and no checkout can + still follow them. The first rule's sentence that the one-line install + is the human's to run now names the pipx install too, and was reworded + to cost no line.""" lines = (REPO / "AGENTS.md").read_text().splitlines() - assert len(lines) <= 495, f"AGENTS.md is {len(lines)} lines; the cap is 495" + assert len(lines) <= 494, f"AGENTS.md is {len(lines)} lines; the cap is 494" def test_claude_md_points_at_agents_md(): From b006f163300a3e5eed07cd901b373cf8fdc13234 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 09:58:25 +0000 Subject: [PATCH 47/58] docs/release-notes/v1.1.0.md: refreshed at main 4cf5fb0 (49 pull requests, 57 of 82 milestone issues closed), with #583, #596, #614, #619, #594, #617 and #618 in their areas, #596's Git Bash facts for its placeholders, release.yml's step 1 whole, and every pull request cited beside its issue (RV630 SHOULD 3, 5, 6, nits 7, 8) - SHOULD 5: step 1 matches release.yml's header: "always on a `release/v` branch, which no push run tests", "A `release/v` branch serves while it is still at that commit", and that a tag's dispatch running at the tag's commit is GitHub's documented behaviour, not yet seen here. - SHOULD 6: #598 beside #595, #592 beside #593, #460 on #557's line, and #589's line says "where that clone is still on the machine and is no partial clone" again. The counts and the open-issue list are re-derived from `git log --first-parent bfed143..origin/main` (49) and the milestone (25 open, 57 closed: #342, #392, #393, #421, #423, #426, #432, #433, #434, #443, #444, #454, #455 and #629 joined it). New lines under adopt-project.py for #583 (#520, #420), #619 (#530), #618 (#418), #614 (#527), #594 (#526) and #617 (#525); #596 (#585) under the one-line install, CI, native Windows, the docs and the known limits, where the two placeholders for it were. - SHOULD 3: the known limit about "the installed doctor's temporary clone" is gone; the README, the handbook and AGENTS.md no longer say it. - SHOULD 1: the filter-repo line says install.sh's order, apt and dnf included. - nit 7: the release process's preconditions are said as the process this release follows; the workflow checks the environments, and nothing checks the rehearsal. - nit 8: the known limits are wrapped. - "the first on native Windows, where the one line cannot run" says PowerShell cannot run it, since Git Bash does. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/v1.1.0.md | 166 +++++++++++++++++++++++------------ 1 file changed, 108 insertions(+), 58 deletions(-) diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index d7f73a59..89a4e45a 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -1,18 +1,19 @@ # openRepoShape v1.1.0 +request #630; refreshed 2026-10-16 at main `4cf5fb0`. Every +`**[placeholder]**` below, and every section headed "remove at tag time", is +filled in or cut before the tag. --> v1.1.0 puts openRepoShape on PyPI. `pipx install openreposhape` is a second way in, beside the one-line install, and the first on native Windows, where -the one line cannot run (#563). This release also carries v1.0.1's one-line -install into main (#564), fixes limits that v1.0.0 listed, and runs CI on -Linux only (#597). +PowerShell cannot run the one line (#563). This release also carries v1.0.1's +one-line install into main (#564), fixes limits that v1.0.0 listed, and runs +CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At drafting time, 42 pull requests -had landed on main since v1.0.0's commit, `bfed143`, and 49 of the `v1.1.0` -milestone's 68 issues were closed. +**[placeholder: the counts at tag time.]** At main `4cf5fb0`, on 2026-10-16, +49 pull requests had landed on main since v1.0.0's commit, `bfed143`, and 57 +of the `v1.1.0` milestone's 82 issues were closed. ## What 1.1.0 adds @@ -54,9 +55,12 @@ milestone's 68 issues were closed. - it installs git-filter-repo with `uv` where there is no `pipx`, and never with `pip install --user`, which an externally managed Python refuses (PEP 668). With neither, it prints one command for this machine: brew's, - pipx's from apt or dnf, pacman's, or uv's (#595); + pipx's from apt or dnf, pacman's, or uv's (#598, #595); - it ends with two lines, `adoption:` and then `done:`, so a machine that - is ready to scaffold but not to adopt is told so (#595); + is ready to scaffold but not to adopt is told so (#598, #595); + - Git Bash, the bash that Git for Windows installs, runs it and the + `openRepoShape` it places, tested in CI but for a typed `yes`, which the + preflight cannot take there (#596, #585); - it refuses a target that is a symbolic link, such as the `openRepoShape` pipx places, and so does the launcher's `--install` (#563). @@ -65,9 +69,12 @@ milestone's 68 issues were closed. suite on `ubuntu-latest`, on every push to main, every pull request and every dispatch. Since #593 each push run on main is grouped by its own commit, so the next landing no longer cancels it, and every landing keeps - its own Linux result. The suite no longer exercises Windows file-system - behaviour, and the stock macOS bash 3.2 is a goal the bash entry points - are written for rather than a checked claim. The release workflow still + its own Linux result. One exception, on his word the same day: a + `tests-git-bash` job runs the launcher's and `install.sh`'s suites, and + nothing else, on Windows under Git Bash (#596, #585). The rest of the + suite no longer exercises Windows file-system behaviour, and the stock + macOS bash 3.2 is a goal the bash entry points are written for rather + than a checked claim. The release workflow still installs the package on Linux, macOS and Windows before it reaches PyPI. ## Install and upgrade @@ -91,8 +98,9 @@ milestone's 68 issues were closed. - **Native Windows:** `pipx install openreposhape`, then `openRepoShape --org `. Downloading `setup-project.py` and running `py setup-project.py --org ` still works, - and installs nothing. **[placeholder: lane openRepoShape-5's sentence for - Git Bash on Windows, from #596 (#585), once it lands.]** + and installs nothing. Git Bash runs the one line and the bash + `openRepoShape` it places, tested in CI but for a typed `yes`, which the + preflight cannot take there (#596, #585). - **A project's pin** on the standard works as before. Follow [How to pin 1.0.0](v1.0.0.md#how-to-pin-100) with the v1.1.0 release commit. A scaffold run through the package pins that commit and digest by @@ -115,9 +123,31 @@ release commit.]** before any leg exists, a file the root keeps at the shape's own pin path and a `--work-dir` the user cannot write in. Both used to fail only after both legs were pushed (#581, #524). `check` recomputes each neutral pin's - digest at the plan's commit, in the clone `plan` read it from, so a commit - or a digest edited into the plan no longer reaches the root as - `adoption verified` over a red `validate-pins.py` (#589, #449). + digest at the plan's commit, in the clone `plan` read it from, where that + clone is still on the machine and is no partial clone, so a commit or a + digest edited into the plan no longer reaches the root as + `adoption verified` over a red `validate-pins.py` (#589, #449). Before any + leg too: the branch names `execute` failed on, such as + `adopt_branch: a..b`, a source branch `Adopt` or one hidden by `hideRefs` + (#583, #520, #420); a kept nested `.gitattributes` that outranks the + root's line-ending rule for a pinned copy, and a root `.gitattributes` + that `git add` would re-encode (#619, #530); and a kept submodule url on + this disk where `git ls-remote` reaches no repository, which used to end + `adoption verified` over a recursive clone that fails (#618, #418). +- **Names outside ASCII.** `execute` hands `git rm` each path the split + moves or drops as the bytes the tree holds, so such a name under + `LC_ALL=C PYTHONUTF8=0` no longer stops the run with `UnicodeEncodeError` + after both legs are pushed (#614, #527). +- **The work directory.** A run without `--work-dir` removes the work + directory it made where it holds no file, and `execute` names the one it + keeps. `check` and `execute` clone the plan's `source.repository` again + where the clone it records is gone, so neither leaves an empty + `openreposhape-adopt-*` directory in the system temp folder, nor refuses + `source-unclonable` once that folder is cleaned (#594, #526). +- **Answers kept.** `execute` records the plan's answers under + `answered_paths:`, and a moved submodule names its row, so an answered + `examples/` is not asked again, and the doctor names no plan for a + deliberate move (#617, #525). - **Leg paths that are not the defaults.** The Makefile block `execute` appends, and the cross-leg follow-up, name each leg where the plan mounts it, so `--spec-path legs/spec` exports a `CONTRACTS_DIR` that exists and a @@ -136,12 +166,12 @@ release commit.]** - **Symlinks checked out as files.** `check`'s finding for a link kept above a path the shape writes says that the clone holds it as a file (#584, #531). -- **Installing git-filter-repo.** `filter-repo-missing` names - `pipx install git-filter-repo==2.47.0`, else - `uv tool install git-filter-repo==2.47.0`, else brew's or pacman's - package, and never `pip install --user`. It says that - `pipx install openreposhape` brings it for the `adopt-project` command it - places (#563). +- **Installing git-filter-repo.** `filter-repo-missing` names its install + in `install.sh`'s order: `pipx install git-filter-repo==2.47.0`, else + `uv tool install git-filter-repo==2.47.0`, else brew's, pipx's from apt or + dnf, or pacman's. It led with a `pip install` that an externally managed + Python refuses (PEP 668). It says that `pipx install openreposhape` brings + it for the `adopt-project` command it places (#563). ### update-shape.py and bump-leg.py @@ -223,7 +253,7 @@ release commit.]** `REFUSED`, and any other non-zero exit is a crash, not a finding about the project. A crash of the standard's own naming script is `CANNOT ANSWER`, and a pass that printed `WARNING` or note lines is a `note` that quotes - them (#557, #410, #447, #448). + them (#557, #410, #447, #448, #460). - **NEXT lines.** The `legs` and `members` NEXT names every detached commit it would move, and sends a mount with no `.git`, an unborn HEAD or no url to a line that cures it, or to none (#559, #413, #414). `manifest kinds` @@ -238,18 +268,21 @@ release commit.]** ### Install, CI and the docs -- **PyPI** (#563) and **the one-line install** (#564, #595), above. +- **PyPI** (#563) and **the one-line install** (#587, #564; #598, #595), + above. - **CI** on Linux only (#597), with a result for every landing on main - (#593). Every test that clones a just-pushed local remote now clones it - in a way git's background maintenance cannot race (#554, #544). + (#593, #592), and a Git Bash job on Windows (#596, #585). Every test that + clones a just-pushed local remote now clones it in a way git's background + maintenance cannot race (#554, #544). - **The docs.** README.md and the handbook say how to install from PyPI, - first on native Windows, and how to install git-filter-repo without - `pip install --user` (#563). Each handbook section records the hash of - every README section it reads, and README's line cap is a base plus one - file per raise in `tests/readme-cap.d/` (#551, #519). The by-hand setup - commands dry-run the scaffold with the `--visibility` and `--elected-by` - setup passes, and `apply --add`'s five refusals are listed by their codes - (#579, #550, #429). + first on native Windows, and that the one line installs git-filter-repo or + prints the command for the machine (#563), and that Git Bash runs the one + line, tested in CI but for a typed `yes` (#596, #585). Each handbook section + records the hash of every README section it reads, and README's line cap is + a base plus one file per raise in `tests/readme-cap.d/` (#551, #519). The + by-hand setup commands dry-run the scaffold with the `--visibility` and + `--elected-by` setup passes, and `apply --add`'s five refusals are listed by + their codes (#579, #550, #429). ## Known limits @@ -260,41 +293,57 @@ here only what is still open at tag time.]** New in v1.1.0: -- A root scaffolded or adopted with `--pin-source` before v1.1.0 records the source's local path in `digest_source:` in `contracts/-pin.yaml`. Editing it to `local-clone` removes the path from the tip, and git history keeps it. -- A NEXT line pasted as `python3 /adopt-project.py …` runs outside the package, so on a machine whose only git-filter-repo is the package's, it is refused `filter-repo-missing`. The refusal says the package's `adopt-project` has it. -- The package on Windows: CI runs its tests on Linux only (#597). The release's install check runs `--version`, `--preflight`, `--doctor`, the four tools' `--help` and, under PowerShell, `openRepoShape --version` there, and no test scaffolds through it on Windows. -- The README, the handbook and AGENTS.md describe the installed doctor's clone as the bash command's temporary one. The package's `--doctor` runs from its cached clone, which its last line names. -- **[placeholder: Git Bash on Windows, lane openRepoShape-5's text from #596 (#585).]** +- A root scaffolded or adopted with `--pin-source` before v1.1.0 records + the source's local path in `digest_source:` in + `contracts/-pin.yaml`. Editing it to `local-clone` removes the + path from the tip, and git history keeps it. +- A NEXT line pasted as `python3 /adopt-project.py …` runs outside + the package, so on a machine whose only git-filter-repo is the package's, + it is refused `filter-repo-missing`. The refusal says the package's + `adopt-project` has it. +- The package on Windows: CI runs its tests on Linux only (#597). The + release's install check runs `--version`, `--preflight`, `--doctor`, the + four tools' `--help` and, under PowerShell, `openRepoShape --version` + there, and no test scaffolds through it on Windows. +- Git Bash on Windows: the preflight that the one line ends with cannot take + a typed `yes` there, so none of its offers can be accepted, and CI runs + only the launcher's and `install.sh`'s suites there (#596, #585). ## How this release is cut (remove at tag time) `.github/workflows/release.yml` publishes the wheel, and its header's -"RELEASING TO PyPI" is the authority for these steps. Nothing is uploaded -until Brett Heap has made the trusted publishers on pypi.org and -test.pypi.org and the `testpypi` and `pypi` environments, and the rehearsal, -a dispatch on a branch, has passed every job it runs. +"RELEASING TO PyPI" is the authority for these steps. The release follows +Brett Heap's own acts, made once: the trusted publishers on pypi.org and +test.pypi.org, and the `testpypi` and `pypi` environments, which the build +job checks before every dispatch. It also follows a rehearsal, a dispatch on +a branch that passes every job it runs, which no workflow checks: it is this +process's step, done before the tag. **[placeholder: the rehearsal's run, and the date of Brett Heap's acts.]** 1. **The release commit passes `tests` on itself**, in a `push` or `workflow_dispatch` run of `tests.yml` whose head is that commit. The build job refuses any other commit, and a pull request's run, or a run on another commit whose tree is the same, does not count: v1.0.1's - tree-equality check is not this release's gate. Main's push run counts - when it finished green; since #593 the next landing no longer cancels it. - Otherwise, before tagging, dispatch `tests.yml` on a branch that points at - the release commit, and wait for `tests` to pass: + tree-equality check is not this release's gate. On main, the push run + that landed it counts when it finished green; since #593 the next landing + no longer cancels it. Otherwise, and always on a `release/v` branch, + which no push run tests, dispatch `tests.yml` before tagging on a branch + that points at the release commit, and wait for `tests` to pass: ```sh git push origin :refs/heads/ gh workflow run tests.yml --ref -R opensoft/openRepoShape ``` + A `release/v` branch serves while it is still at that commit. `--ref main` tests main's current head, not an older commit. If the tag is pushed already and the gate refused it, dispatch on the tag, `gh workflow run tests.yml --ref v1.1.0`, wait for it, and re-run the - release run. + release run. That a tag's dispatch runs at the tag's commit is GitHub's + documented behaviour, not yet seen here; a branch at the commit is the + proven route. 2. **Tag that commit, annotated, and push the tag.** The commit must be on `main`, or on `release/v1.1`: @@ -350,15 +399,16 @@ by moving a tag. - **``** stands for the release commit wherever it appears above. - **Not landed at drafting time:** #590 (the package), #599 (the release - workflow) and the docs pull request for #563, which land in that order, - last before the release, because `pipx install openreposhape` works only - once 1.1.0 is on PyPI. -- **Open `v1.1.0` milestone issues at drafting time (19 open, 49 closed):** - - with a pull request open: #418 (#618), #520 (#583), #525 (#617), #526 - (#594), #527 (#614), #530 (#619), #563 (#590, #599 and the docs pull - request), #585 (#596), #600 (#628), #601 (#620), #602 (#621), #606 - (#627), #608 (#623), #609 (#622), #612 (#626); - - with none: #420, #607, #610, #611. + workflow) and #630, the docs pull request for #563, which land in that + order, last before the release, because `pipx install openreposhape` works + only once 1.1.0 is on PyPI. +- **Open `v1.1.0` milestone issues on 2026-10-16 (25 open, 57 closed):** + - with a pull request open: #342 (#636), #392 and #393 (#635), #421 + (#643), #423 and #426 (#641), #432 (#632), #433 (#640), #434 (#633), + #443 (#631), #444 (#638), #454 and #629 (#642), #563 (#590, #599 and + #630), #600 (#628), #601 (#620), #602 (#621), #606 (#627), #607 (#637), + #608 (#623), #609 (#622), #610 (#634), #611 (#639), #612 (#626); + - with none: #455. Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone. From 61b3907981034fe2c2f343ebc7445bcdd9a0a0e4 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 12:31:05 +0000 Subject: [PATCH 48/58] docs/handbook.html and docs/release-notes/v1.1.0.md: step 3's Windows tab shows the one-line install for Git Bash, held to the other copies by a test, and the notes are current to main d4a7fab with the fifteen pull requests since 4cf5fb0, the environments, the tag ruleset and the rehearsal (#563, #461) The handbook (#461's line "The handbook's step 3 has no Git Bash path", which lane 3 told lane 5 on #473 that #630 settles): step 3's install block was `data-platform="linux mac wsl"`, so a reader on the Windows tab never saw the one line the Windows panel says Git Bash runs. The Windows tab now has a lead, "In Git Bash, the bash that Git for Windows installs, the one-line install works as on Linux:", the Linux tab's block copied byte for byte as `data-lang="sh"`, and the panel's caveat: the preflight it ends with cannot take a typed `yes` in Git Bash. README.md is unchanged: no test asked for it, and it is at its cap. tests/test_repo_hygiene.py: the test above it finds one copy of the one line per file; `test_every_handbook_copy_of_the_one_line_install_is_the_same` holds every handbook copy (the masthead's and step 3's two) to ONE_LINE_INSTALL, so a copy edited on one tab alone fails. A drifted Windows copy fails it; the page as committed passes. The release notes: - "What changed since v1.0.1" gains one entry per pull request that landed after 4cf5fb0 and is not #563's own, from `git log --first-parent 4cf5fb07..origin/main` at d4a7fab: under update-shape.py and bump-leg.py, #644, #633 and #631; under scaffold, family and the materializer, #622, #643, #640, #641, #623, #638, #635, #626, #637 and #642; under shape-doctor.py, #636 and #632. Each from its squash commit and the issues GitHub says it closes. - The list's placeholder says it is current to main d4a7fab and stays a placeholder for what lands after it; the counts are refreshed inside their placeholder (66 pull requests, 75 of 97 milestone issues closed). - #590 and #599 are cited beside #563, and "Before tagging" says they landed (fe237ec, 32c22b0) and lists the 22 open milestone issues. - "How this release is cut": Brett Heap's word at 2026-10-16T12:06:40Z, the environments testpypi (12:07:31Z) and pypi (12:07:39Z), the tag ruleset 25237399 (12:08:14Z), and the rehearsal, run 38898735926 (12:10:53Z-12:12:00Z, success, both uploads skipped), each read back from the API. The trusted publishers' date stays a placeholder. Co-Authored-By: Claude Opus 5.5 --- docs/handbook.html | 6 ++ docs/release-notes/v1.1.0.md | 131 +++++++++++++++++++++++++++++------ tests/test_repo_hygiene.py | 13 ++++ 3 files changed, 128 insertions(+), 22 deletions(-) diff --git a/docs/handbook.html b/docs/handbook.html index c1c09931..5f2c0dca 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -826,6 +826,12 @@

    Install the commands with pipx, or fetch the flow

    an exhausted stdin, so it cannot ask for the one yes before three repositories exist — it refuses instead. Downloading the file first leaves stdin attached to the terminal you are typing at.

    +

    In Git Bash, the bash that Git for Windows installs, the + one-line install works as on Linux:

    +
    curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
    +

    It places the bash openRepoShape, with the panel’s + caveat: the preflight it ends with cannot take a typed yes in Git Bash, so + none of its offers can be accepted there.

    Each way installs ONE command into ~/.local/bin, idempotently — a second run prints unchanged — and prints the export PATH=… line if that directory is not on PATH. diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index 89a4e45a..474cd0dd 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -11,14 +11,14 @@ PowerShell cannot run the one line (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At main `4cf5fb0`, on 2026-10-16, -49 pull requests had landed on main since v1.0.0's commit, `bfed143`, and 57 -of the `v1.1.0` milestone's 82 issues were closed. +**[placeholder: the counts at tag time.]** At main `d4a7fab`, on 2026-10-16, +66 pull requests had landed on main since v1.0.0's commit, `bfed143`, and 75 +of the `v1.1.0` milestone's 97 issues were closed. ## What 1.1.0 adds -- **openRepoShape on PyPI, as `openreposhape`** (#563). One command installs - it: +- **openRepoShape on PyPI, as `openreposhape`** (#590, #599, #563). One + command installs it: ```sh pipx install openreposhape @@ -109,9 +109,9 @@ of the `v1.1.0` milestone's 82 issues were closed. ## What changed since v1.0.1 v1.0.1 was cut from `release/v1.0` and added only the one-line install. -Everything below landed on main after v1.0.0's commit, by area. -**[placeholder: the pull requests that land between this draft and the -release commit.]** +Everything below landed on main after v1.0.0's commit, by area. The list +is current to main `d4a7fab`. **[placeholder: the pull requests that land +after main `d4a7fab` and before the release commit.]** ### adopt-project.py @@ -202,10 +202,23 @@ release commit.]** another branch whose tracking branch is behind the pin is fast-forwarded and checked out, and the shipped text says bootstrap places a leg where that loses no commit (#615, #603). +- **A branch name git cannot take.** A `tracking_branch:` or a leg's + `branch:` that holds a NUL or a lone surrogate is a `validate-manifest.py` + finding, and `make bootstrap` does not hand it to git, so it no longer + ends in a traceback under a `manifest ok` (#644, #455). - **The pinned validators.** THE LOCKSTEP RULE is printed only where a leg or member is out of step. A lone digest or workflow-reference mismatch names its `bump-leg.py` line, and a holder's remedies name the branch to make first (#575, #539). +- **A workflows directory the pins gate cannot read.** `validate-pins.py` + refuses a `.github/workflows/` directory this user cannot list or search + as `workflow-unreadable`, naming it, exit 2, where it ended in a + `PermissionError` traceback, exit 1 (#633, #434). +- **A pin list that is not a list.** A project's `neutral_product_pins:` or + a leg's `naming.referent_chain:` that is not a list is + `validate-manifest.py`'s finding for that field in every gate that reads + it, so `5` no longer ends `make validate` in a `TypeError`, and a scalar + `openXdox` is no longer read as its letters (#631, #443). ### scaffold, family and the materializer @@ -213,6 +226,26 @@ release commit.]** and `family.py init --dry-run` ask whether the remotes already exist, so `scaffold-remote-exists` and `family-remote-exists` come at the plan, and a missing `gh` is `gh-not-found`, not a traceback (#573, #537). +- **Folders, asked before they are made.** `scaffold-project.py` and + `family.py init` ask whether each folder they make can be made, and take + only `gh`'s not-found answer as absent, so `--dry-run` refuses a file + where a folder goes, or a `gh repo view` that fails another way, by name, + as the real run does, and a refused `init` leaves no empty family folder + (#622, #609). +- **`--tracking-branch`.** The tracking branch is pushed by its full name, + so `--tracking-branch +x` scaffolds a project and initialises a family + instead of failing at the push after every remote exists (#643, #421). +- **`--local-remote-dir`.** A `--local-remote-dir` whose clone URL is not + text a README can hold, such as a directory named in a legacy code page, + is refused before any remote or folder exists, `--dry-run` alike, where + it ended in a `UnicodeEncodeError` traceback after the bare remotes were + made (#640, #433). +- **Names that are not UTF-8.** In `bump-leg.py`, `scaffold-project.py` and + `family.py`, a file name that is not UTF-8 in `git status` is + `bump-leg-root-dirty` or the scaffold's DIRTY warning, and a + `git config user.name` that is not UTF-8 is refused + `family-unwritable-value`, naming `--created-by`, where each ended in a + `UnicodeDecodeError` traceback (#641, #423, #426). - **A re-run's legs.** A leg that `gh` sees and git cannot read is refused `scaffold-remote-exists`, with git's terminal prompt off, and the reuse re-run's visibility lines go by each leg as it stands (#565, #542). @@ -223,9 +256,23 @@ release commit.]** STEPS wait for step (7), step (3) refuses a file above the clone, `-- --dry-run` ends at the plan, step (4) hears the pins passed to it, and the upstream-organisation guard ignores case (#569, #538). +- **setup-project.py's checks before a run.** Step (3) refuses a parent + folder nobody can write in and a drive that is not there, step (4) + verifies a chain from `--pin-source`, `--pin` no longer takes a flag as + its value, and step (5) says when a printed plan was refused (#623, + #608). - **Pin chains.** A bare `--link-source` is the tree of the chain's first link alone, and a source that names no path is refused (#576, #441, #442). +- **One product, one spelling.** A chain link, a declared pin and a + `--link-source` key name one product exactly when they share a pin file, + so a tree declaring `openss` no longer verifies a chain that says + `openß`, a chain naming both is two links and not a cycle, and + `--link-source openss=` no longer answers for `openß` (#638, #444). +- **The YAML reader.** A plain value opening with an indicator, an empty + flow entry and a single pair in a flow sequence are refused, so `k: ]` is + not the text `]`, `pins: [,a]` is not `['a']`, and `k: [a: b]` is not the + string `'a: b'` (#635, #392, #393). - **family.py.** `add` refuses, before the member is read and on every disk, any path under another spelling of `members/`, with a remedy per entry that never moves the members' checkouts (#574, #362, #363, #364). `add` @@ -238,6 +285,22 @@ release commit.]** `make siblings` asks about the family folder before it clones, clones each member on its tracking branch, and `family.py siblings` runs the holder's own copy (#578, #543). +- **family.py refuses before it writes.** `add`, `bump` and `remove` refuse, + before their first write, a merge, cherry-pick, revert, rebase or + `git am` in progress, a `.gitmodules`, `members/` or `family.yaml` that + the sparse checkout leaves out, and a file named `members`. Each failed + after writing and was rolled back, or was committed in the wrong place + (#626, #612). +- **The `switch -c` remedy.** Where an earlier act left the branch, + `family.py` and `validate-family.py` say to give the new branch another + name, or to go back to the old one only if it holds work not merged yet, + and the docs spell it `family/add-` (#637, #607). +- **A family's name, printed.** `make siblings`, `park` and `resume` print + the family's name and every member row, and no longer end in a traceback + on a name such as `"a\ud83db"` or on a dash under + `PYTHONIOENCODING=ascii`. The holder's README names its folder after the + repository, so `--family Quill --name QuillWorks` lays out as its README + says (#642, #454, #629). ### shape-doctor.py @@ -254,6 +317,15 @@ release commit.]** project. A crash of the standard's own naming script is `CANNOT ANSWER`, and a pass that printed `WARNING` or note lines is a `note` that quotes them (#557, #410, #447, #448, #460). +- **A root that lost `repo_shape.py`.** Every validator the doctor runs + ignores site-packages, so a `.pth` file in the user's or a virtualenv's + site-packages no longer stands in for the `scripts/repo_shape.py` a root + lost: the `manifest` row is the gate's own `ModuleNotFoundError`, as in + CI (#636, #342). +- **A holder's `.gitmodules` that is not UTF-8.** `--root` on a family + holder reads a member url that is not UTF-8 as `make siblings` does, so a + member mounted from a legacy-code-page directory gets a verdict, where the + doctor ended in a `UnicodeDecodeError` traceback, exit 1 (#632, #432). - **NEXT lines.** The `legs` and `members` NEXT names every detached commit it would move, and sends a mount with no `.git`, an unborn HEAD or no url to a line that cures it, or to none (#559, #413, #414). `manifest kinds` @@ -268,8 +340,8 @@ release commit.]** ### Install, CI and the docs -- **PyPI** (#563) and **the one-line install** (#587, #564; #598, #595), - above. +- **PyPI** (#590, #599, #563) and **the one-line install** (#587, #564; + #598, #595), above. - **CI** on Linux only (#597), with a result for every landing on main (#593, #592), and a Git Bash job on Windows (#596, #585). Every test that clones a just-pushed local remote now clones it in a way git's background @@ -320,7 +392,23 @@ test.pypi.org, and the `testpypi` and `pypi` environments, which the build job checks before every dispatch. It also follows a rehearsal, a dispatch on a branch that passes every job it runs, which no workflow checks: it is this process's step, done before the tag. -**[placeholder: the rehearsal's run, and the date of Brett Heap's acts.]** + +On Brett Heap's word at 2026-10-16T12:06:40Z ("can you do the github work +with my gh creds?"), lane openRepoShape-3 made, as brettheap, the +environments `testpypi` (12:07:31Z) and `pypi` (12:07:39Z), each with +brettheap as its required reviewer, no admin bypass and one deployment rule, +the tags `v*`; and the tag ruleset 25237399 (12:08:14Z), under which only +the repository admin role creates, moves or deletes a `v*` tag. The +rehearsal passed: `gh workflow run release.yml --ref l3-rehearsal-v1.1.0 +-f version=1.1.0`, on a branch at main `a5245427`, run +[38898735926](https://github.com/opensoft/openRepoShape/actions/runs/38898735926), +from 12:10:53Z to 12:12:00Z. Its build and verify job passed, both +environment checks and the tests gate (main's push run 38894482092 at that +commit) included, and so did the install check on ubuntu-latest, +macos-latest and windows-latest with Python 3.14.8, and on ubuntu-latest +with Python 3.9.25. Both uploads were skipped, as on every branch. +**[placeholder: the date Brett Heap makes the pending trusted publishers on +pypi.org and test.pypi.org.]** 1. **The release commit passes `tests` on itself**, in a `push` or `workflow_dispatch` run of `tests.yml` whose head is that commit. The @@ -398,17 +486,16 @@ by moving a tag. - **``** stands for the release commit wherever it appears above. -- **Not landed at drafting time:** #590 (the package), #599 (the release - workflow) and #630, the docs pull request for #563, which land in that - order, last before the release, because `pipx install openreposhape` works - only once 1.1.0 is on PyPI. -- **Open `v1.1.0` milestone issues on 2026-10-16 (25 open, 57 closed):** - - with a pull request open: #342 (#636), #392 and #393 (#635), #421 - (#643), #423 and #426 (#641), #432 (#632), #433 (#640), #434 (#633), - #443 (#631), #444 (#638), #454 and #629 (#642), #563 (#590, #599 and - #630), #600 (#628), #601 (#620), #602 (#621), #606 (#627), #607 (#637), - #608 (#623), #609 (#622), #610 (#634), #611 (#639), #612 (#626); - - with none: #455. +- **Not landed at drafting time:** #630, the docs pull request for #563, + which lands last before the release, because `pipx install openreposhape` + works only once 1.1.0 is on PyPI. #590 (the package, `fe237ec`) and #599 + (the release workflow, `32c22b0`) have landed. +- **Open `v1.1.0` milestone issues at main `d4a7fab` (22 open, 75 closed):** + - with a pull request open: #563 (#630), #600 (#628), #601 (#620), #602 + (#621), #606 (#627), #610 (#634), #611 (#639), #647 (#657), #649 + (#656); + - with none: #388, #389, #417, #425, #645, #646, #648, #650, #651, #652, + #653, #654, #655. Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone. diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 91b2f5eb..00fc68b9 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -1575,6 +1575,19 @@ def test_the_one_line_install_is_the_same_everywhere(name): f" {ONE_LINE_INSTALL}") +def test_every_handbook_copy_of_the_one_line_install_is_the_same(): + """The handbook shows the one line in three code blocks: the masthead's, + step 3's on the Linux, macOS and WSL2 tabs, and step 3's on the Windows + tab, for Git Bash (#461's line "The handbook's step 3 has no Git Bash + path", settled by #630). The test above finds ONE copy; this one holds + every copy, so a line edited on one tab alone fails here.""" + page = (REPO / "docs" / "handbook.html").read_text(encoding="utf-8") + copies = [line for block in re.findall(r"(.*?)", page, re.S) + for line in block.splitlines() if "download/install.sh" in line] + assert len(copies) >= 3, copies + assert set(copies) == {ONE_LINE_INSTALL}, copies + + #: Where each document describes `openRepoShape --doctor`: the file, the #: line its passage begins with, what ends it, and whether it describes the #: installed form's kept clone. `docs/cli.md` has two: the doctor's purpose in From ddd3c5ff085d467b45c3ade468d3defe87e9f070 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 12:47:14 +0000 Subject: [PATCH 49/58] docs/handbook.html and docs/release-notes/v1.1.0.md: step 3's Git Bash route claims only that the one line runs, under a Windows heading that names it, and the notes carry #620, #596's `ln -s` fact, headings that fit their entries, whose YAML reader it is and who made the environments (RV630 round 3: SHOULD 1-2, nits 1-5) The handbook: - SHOULD 1: "In Git Bash, the one-line install runs too:", where it said "works as on Linux", which #596 does not support: `ln -s` makes a copy there and `chmod` changes nothing. - nit 1: the step no longer repeats the panel's definition of Git Bash, and states the caveat once, without pointing at the panel as well. - nit 2: the Windows tab's heading names the third way, "Install with pipx, fetch the flow, or use Git Bash's one line". The release notes: - SHOULD 2: the Git Bash known limit says, as #596 and the launcher's comment do, that `ln -s` makes a copy there unless `MSYS=winsymlinks:nativestrict` is set, and a copy has no `openRepoShape.ref` beside it, so it follows main, not the installed release; link it with that setting, or put `~/.local/bin` on `PATH`. - nit 3: "update-shape.py, bump-leg.py and a project's own scripts" and "scaffold, setup, family and the shared readers"; no entry moved. - nit 4: the YAML reader is the standard's own, in `repo_shape.py`, which the tools use to read `project.yaml` and the pin files. - nit 5: the release process's acts made once say who made which: the trusted publishers are Brett Heap's to make; lane openRepoShape-3 made the environments and the tag ruleset as brettheap on his word. - #620 (#601), merged from main 879d804, under adopt-project.py; the list is current to main 879d804; the counts (67 pull requests; 76 of 104 milestone issues closed at 12:43Z) and the open list (28) are refreshed, and stay placeholders. Co-Authored-By: Claude Opus 5.5 --- docs/handbook.html | 10 +++---- docs/release-notes/v1.1.0.md | 57 +++++++++++++++++++++--------------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/docs/handbook.html b/docs/handbook.html index 5f2c0dca..70940bc8 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -785,7 +785,7 @@

    Make one machine setting first

  • Install the openRepoShape command, once

    Install the openRepoShape command, once, inside WSL

    -

    Install the commands with pipx, or fetch the flow

    +

    Install with pipx, fetch the flow, or use Git Bash’s one line

    curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash

    install.sh is attached to every release and installs the newest, with no gh and no login. Its command is PINNED to the @@ -826,12 +826,10 @@

    Install the commands with pipx, or fetch the flow

    an exhausted stdin, so it cannot ask for the one yes before three repositories exist — it refuses instead. Downloading the file first leaves stdin attached to the terminal you are typing at.

    -

    In Git Bash, the bash that Git for Windows installs, the - one-line install works as on Linux:

    +

    In Git Bash, the one-line install runs too:

    curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash
    -

    It places the bash openRepoShape, with the panel’s - caveat: the preflight it ends with cannot take a typed yes in Git Bash, so - none of its offers can be accepted there.

    +

    It places the bash openRepoShape, whose preflight cannot + take a typed yes in Git Bash, so none of its offers can be accepted there.

    Each way installs ONE command into ~/.local/bin, idempotently — a second run prints unchanged — and prints the export PATH=… line if that directory is not on PATH. diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index 474cd0dd..6f68079c 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -11,9 +11,9 @@ PowerShell cannot run the one line (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At main `d4a7fab`, on 2026-10-16, -66 pull requests had landed on main since v1.0.0's commit, `bfed143`, and 75 -of the `v1.1.0` milestone's 97 issues were closed. +**[placeholder: the counts at tag time.]** At main `879d804`, on 2026-10-16, +67 pull requests had landed on main since v1.0.0's commit, `bfed143`; at +12:43Z, 76 of the `v1.1.0` milestone's 104 issues were closed. ## What 1.1.0 adds @@ -110,8 +110,8 @@ of the `v1.1.0` milestone's 97 issues were closed. v1.0.1 was cut from `release/v1.0` and added only the one-line install. Everything below landed on main after v1.0.0's commit, by area. The list -is current to main `d4a7fab`. **[placeholder: the pull requests that land -after main `d4a7fab` and before the release commit.]** +is current to main `879d804`. **[placeholder: the pull requests that land +after main `879d804` and before the release commit.]** ### adopt-project.py @@ -163,6 +163,10 @@ after main `d4a7fab` and before the release commit.]** longer offers the seed branch the scaffold dropped (#582, #523). With no `--elected-by` and no `git config user.name`, the refusal says where the empty value came from (#567, #529). +- **An id `plan` derived.** With no `--id`, `plan` derives one from + `--project`, and `adopt-bad-id` now says so: `--project North_Wind` no + longer says `--id 'north_wind' must match` over a flag nobody passed + (#620, #601). - **Symlinks checked out as files.** `check`'s finding for a link kept above a path the shape writes says that the clone holds it as a file (#584, #531). @@ -173,7 +177,7 @@ after main `d4a7fab` and before the release commit.]** Python refuses (PEP 668). It says that `pipx install openreposhape` brings it for the `adopt-project` command it places (#563). -### update-shape.py and bump-leg.py +### update-shape.py, bump-leg.py and a project's own scripts - **A kept copy that would break bootstrap.** `apply --accept-local` refuses, before anything is written, to keep a helper that lacks a name a @@ -220,7 +224,7 @@ after main `d4a7fab` and before the release commit.]** it, so `5` no longer ends `make validate` in a `TypeError`, and a scalar `openXdox` is no longer read as its letters (#631, #443). -### scaffold, family and the materializer +### scaffold, setup, family and the shared readers - **Dry runs ask the real run's question.** `scaffold-project.py --dry-run` and `family.py init --dry-run` ask whether the remotes already exist, so @@ -269,10 +273,11 @@ after main `d4a7fab` and before the release commit.]** so a tree declaring `openss` no longer verifies a chain that says `openß`, a chain naming both is two links and not a cycle, and `--link-source openss=` no longer answers for `openß` (#638, #444). -- **The YAML reader.** A plain value opening with an indicator, an empty - flow entry and a single pair in a flow sequence are refused, so `k: ]` is - not the text `]`, `pins: [,a]` is not `['a']`, and `k: [a: b]` is not the - string `'a: b'` (#635, #392, #393). +- **The YAML reader.** The standard's own reader in `repo_shape.py`, which + the tools use to read `project.yaml` and the pin files, refuses a plain + value opening with an indicator, an empty flow entry and a single pair in + a flow sequence, so `k: ]` is not the text `]`, `pins: [,a]` is not + `['a']`, and `k: [a: b]` is not the string `'a: b'` (#635, #392, #393). - **family.py.** `add` refuses, before the member is read and on every disk, any path under another spelling of `members/`, with a remedy per entry that never moves the members' checkouts (#574, #362, #363, #364). `add` @@ -379,19 +384,25 @@ New in v1.1.0: there, and no test scaffolds through it on Windows. - Git Bash on Windows: the preflight that the one line ends with cannot take a typed `yes` there, so none of its offers can be accepted, and CI runs - only the launcher's and `install.sh`'s suites there (#596, #585). + only the launcher's and `install.sh`'s suites there. Git Bash's `ln -s` + makes a copy unless `MSYS=winsymlinks:nativestrict` is set, and a copy of + the command has no `openRepoShape.ref` beside it, so it follows main, not + the installed release: link it with that setting, or put `~/.local/bin` + on `PATH` (#596, #585). ## How this release is cut (remove at tag time) `.github/workflows/release.yml` publishes the wheel, and its header's -"RELEASING TO PyPI" is the authority for these steps. The release follows -Brett Heap's own acts, made once: the trusted publishers on pypi.org and -test.pypi.org, and the `testpypi` and `pypi` environments, which the build -job checks before every dispatch. It also follows a rehearsal, a dispatch on -a branch that passes every job it runs, which no workflow checks: it is this -process's step, done before the tag. +"RELEASING TO PyPI" is the authority for these steps. The release follows acts +made once, on Brett Heap's behalf: the trusted publishers on pypi.org and +test.pypi.org, which are his to make, and the `testpypi` and `pypi` +environments and the tag ruleset, which lane openRepoShape-3 made as brettheap +on his word (below); the build job checks the environments before every +dispatch. It also follows a rehearsal, a dispatch on a branch that passes +every job it runs, which no workflow checks: it is this process's step, done +before the tag. On Brett Heap's word at 2026-10-16T12:06:40Z ("can you do the github work with my gh creds?"), lane openRepoShape-3 made, as brettheap, the @@ -490,12 +501,12 @@ by moving a tag. which lands last before the release, because `pipx install openreposhape` works only once 1.1.0 is on PyPI. #590 (the package, `fe237ec`) and #599 (the release workflow, `32c22b0`) have landed. -- **Open `v1.1.0` milestone issues at main `d4a7fab` (22 open, 75 closed):** - - with a pull request open: #563 (#630), #600 (#628), #601 (#620), #602 - (#621), #606 (#627), #610 (#634), #611 (#639), #647 (#657), #649 - (#656); +- **[placeholder: re-read at tag time.] Open `v1.1.0` milestone issues at + main `879d804`, 12:43Z (28 open, 76 closed):** + - with a pull request open: #563 (#630), #600 (#628), #602 (#621), #606 + (#627), #610 (#634), #611 (#639), #647 (#657), #649 (#656); - with none: #388, #389, #417, #425, #645, #646, #648, #650, #651, #652, - #653, #654, #655. + #653, #654, #655, #658, #659, #660, #661, #662, #663, #664. Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone. From 5b140bd37d7f3069d8b25b4fb8c79f375a4355f1 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 12:53:45 +0000 Subject: [PATCH 50/58] README.md and the handbook: `validate-pins.py`'s paragraph says again that a digest that recomputes differently is the finding `neutral-pin-digest-mismatch` (exit 1), and that a declared pin file that is not there is the refusal `neutral-pin-missing` (exit 2) (#460 L1) #465 removed a sentence whose other half was false (a missing pin file is the refusal `neutral-pin-missing`, exit 2, not drift), and with it the true half: a digest that recomputes differently is the finding `neutral-pin-digest-mismatch`, exit 1. v1.0.0's notes listed that as a known limit. Both codes read from templates/assembly-root/scripts/ validate-pins.py on this tree: `report.finding("neutral-pin-digest- mismatch", ...)`, which `_emit_report` turns into exit 1, and `raise Refusal("neutral-pin-missing", ...)`, which `main` turns into exit 2. - README.md: the paragraph's last sentences, +2 lines, with tests/readme-cap.d/460.txt (+2) giving the reason. README.md is 1463 lines, its cap: 1422 + 2 (460) + 5 (550) + 12 (563) + 22 (564). - docs/handbook.html: its twin in the `legs` section says the same. The three `The three legs` markers move: `legs` (text and marker), and the masthead and `ci` (marker only: neither says anything about these codes). No other marker moves. - docs/release-notes/v1.1.0.md: one line under "Install, CI and the docs", saying v1.0.0's known limit is closed. Brett Heap gave #460 to lane 3 at 2026-10-16T12:24:41Z; lane 3's triage (T460a) puts this line in #630, whose files it already touches. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++++++---- docs/handbook.html | 11 +++++++---- docs/release-notes/v1.1.0.md | 5 +++++ tests/readme-cap.d/460.txt | 11 +++++++++++ 4 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 tests/readme-cap.d/460.txt diff --git a/README.md b/README.md index bbce2fd1..a4833546 100644 --- a/README.md +++ b/README.md @@ -736,10 +736,12 @@ one is found — `--pin-source [PRODUCT=]`, `SHAPE_PIN_SOURCE_` i the environment, or a checkout beside the assembly root named for the pinned repository — else by `gh api`, with the operator's `gh` credentials, when `gh` can read it. Finding neither is a named SKIP, exit 0: no checkout and no network -does not make a descent a lie. A checkout that cannot answer is the finding -`neutral-pin-source-unreadable`. `make bootstrap`, `bump-leg.py` and -`update-shape.py apply` run this validator in the operator's environment, so -each makes that call too; none takes `--pin-source`. +does not make a descent a lie. A digest that recomputes differently is the +finding `neutral-pin-digest-mismatch` (exit 1), and a checkout that cannot +answer is the finding `neutral-pin-source-unreadable`; a declared pin file that +is not there is the refusal `neutral-pin-missing` (exit 2). `make bootstrap`, +`bump-leg.py` and `update-shape.py apply` run this validator in the operator's +environment, so each makes that call too; none takes `--pin-source`. ### A neutral product may elect the shape and be its own assembly root diff --git a/docs/handbook.html b/docs/handbook.html index 70940bc8..29edd063 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -474,7 +474,7 @@ - + @@ -1124,7 +1124,7 @@

    If it fails part-way

    - +

    Setup

    Reading private legs in CI

    @@ -1182,7 +1182,7 @@

    Reading private legs in CI

    - +

    Reference

    The three legs, and naming

    @@ -1236,7 +1236,10 @@

    The three legs, and naming

    assembly root named for the pinned repository — else by gh api, with the operator’s gh credentials, when gh can read it. Finding neither is a named SKIP, exit 0: no checkout and no network does not make a descent a lie. A - checkout that cannot answer is the finding neutral-pin-source-unreadable. + digest that recomputes differently is the finding + neutral-pin-digest-mismatch (exit 1), and a checkout that cannot answer is the + finding neutral-pin-source-unreadable; a declared pin file that is not there is + the refusal neutral-pin-missing (exit 2). make bootstrap, bump-leg.py and update-shape.py apply run this validator in the operator’s environment, so each makes that call too; none takes --pin-source.

    diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index 6f68079c..3658bd22 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -360,6 +360,11 @@ after main `879d804` and before the release commit.]** by-hand setup commands dry-run the scaffold with the `--visibility` and `--elected-by` setup passes, and `apply --add`'s five refusals are listed by their codes (#579, #550, #429). +- **`validate-pins.py`'s codes for a neutral-product pin.** README.md and the + handbook say again that a digest that recomputes differently is the + finding `neutral-pin-digest-mismatch` (exit 1), whose absence v1.0.0 + listed as a known limit, and that a declared pin file that is not there is + the refusal `neutral-pin-missing` (exit 2) (#630, #460). ## Known limits diff --git a/tests/readme-cap.d/460.txt b/tests/readme-cap.d/460.txt new file mode 100644 index 00000000..e0062f9f --- /dev/null +++ b/tests/readme-cap.d/460.txt @@ -0,0 +1,11 @@ ++2 +2026-10-16 +Two lines for #460's L1, which Brett Heap gave lane 3 at +2026-10-16T12:24:41Z and which rides with #563's docs pull request, #630. +README's `validate-pins.py` paragraph says again what a digest that +recomputes differently is, the finding `neutral-pin-digest-mismatch` +(exit 1), the true half of a sentence #465 removed, and what its false half +got wrong: a declared pin file that is not there is the refusal +`neutral-pin-missing` (exit 2), not drift. That paragraph is where a reader +learns what the pins gate says about a neutral-product pin, and v1.0.0's +notes listed the missing sentence as a known limit. From 6fd33a93be63def5a86c73a118f7a6474bd6daba Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 17:25:10 +0000 Subject: [PATCH 51/58] README.md, the handbook and the v1.1.0 notes: the adopted repository KEEPS ITS NAME (which `--project` must be), ITS IDENTITY AND ITS FULL HISTORY, and the notes are current to main de92614, with seven more pull requests, lane 2's re-run-`plan` line, lanes 4's and 1's known limits, Developer Mode for a Git Bash symlink and the trusted publishers (#563, L4-47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L4-47 (#457, lane 4 on #630 at 14:13:32Z): README.md:771-772 and docs/handbook.html:1276-1277 say "it KEEPS ITS NAME (which `--project` must be), ITS IDENTITY AND ITS FULL HISTORY, and becomes the assembly root". Two lines each, no line added: README.md is still 1473, its cap. The § Adopting marker moves to b23e9f98, text and marker; no other marker moves. - The notes, "What changed since v1.0.1": #628 (#600), with lane 2's line for a plan written before v1.1.0 (#473, 12:53:40Z) after it, and #621 (#602) under adopt-project.py; #657 (#647) under update-shape.py, bump-leg.py and a project's own scripts; #639 (#611) under scaffold, setup, family and the shared readers; #656 (#649), #698 (#679) and #627 (#606) under install, CI and the docs. Every pull request since bfed143 is cited (`git log --first-parent bfed143..de92614`, 74). - Known limits: lane openRepoShape-4's ten lines (#630 issuecomment-6217406337: L4-05, -06, -07, -08, -17, -43, -45, -54, -24, -156) and lane openRepoShape-1's twenty-six D9 lines (#457 issuecomment-6214768828), as two lists, one sentence each; and the Git Bash limit says that Windows lets a non-administrator make a symlink only in Developer Mode, as #596 does. - How this release is cut: the trusted publishers are filled from Brett Heap's 13:04:43Z ("the pypi are added") and 13:07:56Z ("they match what you listed"). - The counts (74 pull requests; 83 of 135 milestone issues closed at 17:16Z), the "current to main" sha and the open list (52) are at main de92614, still placeholders for tag time. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/handbook.html | 6 +- docs/release-notes/v1.1.0.md | 183 ++++++++++++++++++++++++++++++++--- 3 files changed, 172 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index fd34c2e8..354cfa45 100644 --- a/README.md +++ b/README.md @@ -768,8 +768,8 @@ it satisfies no leg form to be admitted into, and is still refused as any leg. ## Adopting an existing repository A repository that already exists is converted **in place**, by `adopt-project.py`, -and this is a ruling (**Brett Heap, 2026-09-02**): it KEEPS ITS NAME, which -`--project` must be, ITS IDENTITY AND ITS FULL HISTORY and becomes the assembly +and this is a ruling (**Brett Heap, 2026-09-02**): it KEEPS ITS NAME (which +`--project` must be), ITS IDENTITY AND ITS FULL HISTORY, and becomes the assembly root, while `-spec` and `-code` are NEW repositories extracted with history-preserving filters. The source is never deleted, never renamed and never force-pushed; the only change to its history is ONE split commit, arriving diff --git a/docs/handbook.html b/docs/handbook.html index 5e323a0f..b193e1d6 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1269,12 +1269,12 @@

    The three legs, and naming

    - +

    Reference

    Adopting an existing repository

    -

    A repository that already exists is converted in place. It KEEPS ITS NAME, - which --project must be, ITS IDENTITY AND ITS FULL HISTORY and becomes the assembly root, while +

    A repository that already exists is converted in place. It KEEPS ITS NAME + (which --project must be), ITS IDENTITY AND ITS FULL HISTORY, and becomes the assembly root, while <Project>-spec and <Project>-code are NEW repositories extracted with history-preserving filters. The source is never deleted, never renamed and never force-pushed; the only change to its history is ONE split commit, arriving on a branch diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index 3658bd22..fc709272 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -11,9 +11,9 @@ PowerShell cannot run the one line (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At main `879d804`, on 2026-10-16, -67 pull requests had landed on main since v1.0.0's commit, `bfed143`; at -12:43Z, 76 of the `v1.1.0` milestone's 104 issues were closed. +**[placeholder: the counts at tag time.]** At main `de92614`, on 2026-10-16, +74 pull requests had landed on main since v1.0.0's commit, `bfed143`; at +17:16Z, 83 of the `v1.1.0` milestone's 135 issues were closed. ## What 1.1.0 adds @@ -110,8 +110,8 @@ CI on Linux only (#597). v1.0.1 was cut from `release/v1.0` and added only the one-line install. Everything below landed on main after v1.0.0's commit, by area. The list -is current to main `879d804`. **[placeholder: the pull requests that land -after main `879d804` and before the release commit.]** +is current to main `de92614`. **[placeholder: the pull requests that land +after main `de92614` and before the release commit.]** ### adopt-project.py @@ -152,6 +152,15 @@ after main `879d804` and before the release commit.]** appends, and the cross-leg follow-up, name each leg where the plan mounts it, so `--spec-path legs/spec` exports a `CONTRACTS_DIR` that exists and a nested `--code-path` gets the right `../` path (#572, #522). +- **A leg path edited by hand.** The read-across follow-up names + `legs.code_path` and `legs.spec_path` and no path, and the `contracts/` + rule's reason names none, so the hand edit of a leg's mount that `plan` + asks for leaves no plan-time path in `check`, the split commit or the pull + request (#628, #600). A plan written before v1.1.0 whose `legs.spec_path` + or `legs.code_path` was edited by hand keeps its old value-form + read-across follow-up. `check` accepts it, because `follow_ups:` is free + text, and `execute` carries the stale line into the split commit and the + pull request's body. **Re-run `plan` for such a plan.** - **A source's own `single-repository.yaml`** is asked drop or root, drop by default, under a rule of its own, and no longer gets the general top-level YAML question (#566, #528). @@ -167,6 +176,11 @@ after main `879d804` and before the release commit.]** `--project`, and `adopt-bad-id` now says so: `--project North_Wind` no longer says `--id 'north_wind' must match` over a flag nobody passed (#620, #601). +- **The Free-plan secrets hint.** `adopt-project.py` and + `scaffold-project.py` go by the assembly root's own visibility on GitHub + and name the root as the reason, so a public source adopted with private + legs is no longer told that its organisation secrets resolve to EMPTY + (#621, #602). - **Symlinks checked out as files.** `check`'s finding for a link kept above a path the shape writes says that the clone holds it as a file (#584, #531). @@ -197,6 +211,10 @@ after main `879d804` and before the release commit.]** annotated tag's own id is peeled to its commit in both runs, and a pin layout the rewrite refuses is refused by the dry run too (#561, #534, #438). +- **A `git switch -c` line that is no dead end.** `bump-leg.py` and + `setup-project.py` say under their `git switch -c` line what to do where + an earlier act left that branch, so `already exists` is no dead end, as + `family.py` says since #637 (#657, #647). - **Paste lines.** `bump-leg.py` and `update-shape.py` quote the roots and paths in the lines they print, the doctor quotes a leading `=` for zsh, and the README says the printed commands are for a POSIX shell (#588, @@ -300,6 +318,11 @@ after main `879d804` and before the release commit.]** `family.py` and `validate-family.py` say to give the new branch another name, or to go back to the old one only if it holds work not merged yet, and the docs spell it `family/add-` (#637, #607). +- **A holder's gitlinks and its `.gitmodules`.** A gitlink that no section + of HEAD's `.gitmodules` names is `validate-family.py`'s finding + `member-gitmodules-absent`, `family.py remove` refuses a holder with no + `.gitmodules` before it deinitialises anything, and two gitlink remedies + name what they missed (#639, #611). - **A family's name, printed.** `make siblings`, `park` and `resume` print the family's name and every member row, and no longer end in a traceback on a name such as `"a\ud83db"` or on a dash under @@ -347,10 +370,15 @@ after main `879d804` and before the release commit.]** - **PyPI** (#590, #599, #563) and **the one-line install** (#587, #564; #598, #595), above. +- **The launcher in Git Bash.** Its `machine:` row finds `python`, a + drive-letter `$OPENREPOSHAPE_REPO` is read as a path, a Windows-spelled + `$OPENREPOSHAPE_BIN_DIR` is respelled or refused, and the `tests-git-bash` + job runs `install.sh`'s cases from #598 too (#656, #649). - **CI** on Linux only (#597), with a result for every landing on main (#593, #592), and a Git Bash job on Windows (#596, #585). Every test that clones a just-pushed local remote now clones it in a way git's background - maintenance cannot race (#554, #544). + maintenance cannot race (#554, #544), and the test that holds the doctor's + NEXT lines to no `#` comment tail reads each string whole (#698, #679). - **The docs.** README.md and the handbook say how to install from PyPI, first on native Windows, and that the one line installs git-filter-repo or prints the command for the machine (#563), and that Git Bash runs the one @@ -360,6 +388,10 @@ after main `879d804` and before the release commit.]** by-hand setup commands dry-run the scaffold with the `--visibility` and `--elected-by` setup passes, and `apply --add`'s five refusals are listed by their codes (#579, #550, #429). +- **Three sentences made true.** README.md and the handbook say that a + source's own `single-repository.yaml` record arrives as `leg: drop`, that + the adopted Makefile's `CONTRACTS_DIR` follows the spec leg's mount, and + that a second setup run is refused at the plan (#627, #606). - **`validate-pins.py`'s codes for a neutral-product pin.** README.md and the handbook say again that a digest that recomputes differently is the finding `neutral-pin-digest-mismatch` (exit 1), whose absence v1.0.0 @@ -392,8 +424,118 @@ New in v1.1.0: only the launcher's and `install.sh`'s suites there. Git Bash's `ln -s` makes a copy unless `MSYS=winsymlinks:nativestrict` is set, and a copy of the command has no `openRepoShape.ref` beside it, so it follows main, not - the installed release: link it with that setting, or put `~/.local/bin` - on `PATH` (#596, #585). + the installed release: link it with that setting (Windows lets a + non-administrator make a symlink only in Developer Mode), or put + `~/.local/bin` on `PATH` (#596, #585). + +Kept as known limits by lane openRepoShape-4's checklist triage, which Brett +Heap approved at 2026-10-16T13:36:07Z; each line's text stays on #457 or +#459 ([#630](https://github.com/opensoft/openRepoShape/pull/630#issuecomment-6217406337)): + +- Windows: adopting a source whose path holds a character NTFS refuses, such + as a TAB or a control character, is unverified there, because such a path + cannot be checked out (L4-05). +- Windows: adopting a source whose path is not UTF-8, such as `b'caf\xe9'`, + is unverified there, because such a path cannot be made (L4-06). +- Windows: the submodule plan of a source whose tree names hold a newline, a + TAB or a backslash is unverified there, because such a name is not kept in + a tree (L4-07). +- Windows: adopting a source that tracks symlinks is unverified there, + because they check out as plain files unless `core.symlinks=true` + (Developer Mode or an elevated shell) (L4-08). +- A root adopted before #471's fix, from a source that kept its own root + `.gitattributes`, has no line-ending block, so on Windows its pinned + copies still check out CRLF and `make bootstrap` reports + `shape-copy-drift` (L4-17). +- `adopt-project.py` compares an `origin` with the source by Python's + `casefold`, which is broader than GitHub's ASCII-only folding, so a path + `origin` `.../straße/Atlas.git` and `--org strasse` are taken for one + repository (L4-43). +- `check` says `plan ok` for a source whose own repository is literally + `None/`, `False/` or `0/`, though `execute` still + refuses it (L4-45). +- A test, `tests/test_adopt_ignored_shape_files.py`, feeds + `git hash-object --stdin` a link target as text, not bytes; harmless + today, because no target holds a newline or a non-ASCII byte (L4-54). +- `setup-project.py` strips only C0 control characters and DEL from a value + it prints, so C1 controls and direction-override characters still reach + the terminal (L4-24). +- The YAML reader is a subset by design, and still reads, where PyYAML + refuses or reads another thing, `k: - a`, `k: [- ]`, `k: [e?]`, `k: [|a]`, + a block key that opens with an indicator, and a flow `?a` (L4-156). + +Kept as known limits by lane openRepoShape-1's checklist triage (D9), which +Brett Heap approved at 2026-10-16T11:21:17Z; each line's text stays on #457 +([#457](https://github.com/opensoft/openRepoShape/issues/457#issuecomment-6214768828)): + +- The `--pin-source` digest reader runs git with lazy fetch on and the + terminal prompt allowed, so a partial clone can fetch and a private https + remote can prompt, where the help says it uses no network (L1-02). +- `update-shape.py apply --add` and the doctor read no git attributes, so a + nested `.gitattributes` added after adoption makes CRLF clones drift with + no finding that names the cause (L1-08). +- A setting passed only through a parent git's `-c`, such as + `core.attributesFile`, reaches the split's `git add` but not `check`'s + re-encoding question, so `check` passes and `execute` refuses `adopt-lost` + after both legs exist (L1-10). +- A `transfer.hideRefs` or `receive.hideRefs` pattern that covers the split + branch passes `check`; `execute` makes both legs, and the split push is + then denied (L1-13). +- A tracking branch the source hides from `ls-remote` passes `check`, and + `execute` refuses it `plan-tracking-branch-missing` before any leg + (L1-14). +- With `org:` and `adopt_branch:` both outside the alphabet, `check` + refuses `org` first and `execute` refuses `adopt_branch` first (L1-17). +- Two branch names that differ "only by case" are judged with Python's + `casefold`, which also folds `ß` to `ss` and `ſ` to `s`; how APFS, NTFS + and GitHub treat such pairs is not established (L1-18). +- Under `core.symlinks=false`, a link at the `shape/` copy of a path the + root holds through a link above it gets a finding whose every fix leads on + to `adopt-symlinks-off` (L1-29). +- Under `core.symlinks=false`, `.github -> ci` with `ci -> ""` reports only + `plan-empty-symlink` for `ci` and never names `.github`, so the fix takes + two rounds (L1-30). +- Under `core.symlinks=false`, `check` passes a link above a directory the + root keeps (`scripts -> ci`), and `execute` then refuses + `adopt-symlinks-off` (L1-31). +- A failure of `check`'s probe `git init` other than an `OSError` stops it + with "a git or gh command failed" instead of a finding (L1-32). +- A url whose organisation is refused is a dead end: + `https://example.invalid/café/Atlas.git` with `--org acme` is refused as + not the source, and the suggested `--org café` is refused `unsafe-value` + (L1-40). +- An `origin` that `url..insteadOf` rewrites to a local path reads as + a path, so nothing is compared (L1-41). +- A url's host is never checked, so a GitLab url is compared as GitHub's + (L1-42). +- A drive-relative `origin` such as `C:foo/bar` reads as git's `host:path` + form, though git on Windows reads it as a path (L1-44). +- Only `plan` prints a NOTE for an `origin` that is a path; `check` and + `execute` print none (L1-45). +- `https://github.com/acme//Atlas`, with a doubled slash, names no + repository, so nothing is compared and `plan` exits 0 (L1-46). +- A path `origin` that is not UTF-8 gets no NOTE (L1-47). +- `plan` does not follow a path `origin` to that repository's own `origin`, + which would catch more mistyped `--org` values (L1-48). +- A submodule url naming an empty bare repository answers `ls-remote`, so + `check` passes and `execute` verifies, and the recursive clone then fails + with `upload-pack: not our ref` (L1-50). +- An absolute local-path submodule url that answers on this machine, kept + in a leg pushed to GitHub, works only on machines that have that path, and + nothing notes it (L1-51). +- A path on a mounted network filesystem (NFS, a mapped drive) looks like + this disk to git and is asked; its spelling cannot tell it apart (L1-52). +- The NOTE for a submodule url that is not asked gives its network reason + for a bare relative url such as `dependency`, which names no network + (L1-53). +- On Windows, a UNC `--local-remote-dir` (`\\server\share\remotes`) makes + every relative submodule url resolve to a UNC path, which is never asked + and gets no NOTE (L1-54). +- A test, `tests/test_adopt_pins.py`, writes its stub `gh` with + `Path.write_text`, so a CRLF emulation run fails one case; not a product + bug (L1-60). +- #618's guard test does not clear `GIT_ALLOW_PROTOCOL`, so a caller's + environment could mask a missing guard; not a product bug (L1-62). ## How this release is cut (remove at tag time) @@ -402,7 +544,7 @@ New in v1.1.0: `.github/workflows/release.yml` publishes the wheel, and its header's "RELEASING TO PyPI" is the authority for these steps. The release follows acts made once, on Brett Heap's behalf: the trusted publishers on pypi.org and -test.pypi.org, which are his to make, and the `testpypi` and `pypi` +test.pypi.org, which he made, and the `testpypi` and `pypi` environments and the tag ruleset, which lane openRepoShape-3 made as brettheap on his word (below); the build job checks the environments before every dispatch. It also follows a rehearsal, a dispatch on a branch that passes @@ -423,8 +565,12 @@ environment checks and the tests gate (main's push run 38894482092 at that commit) included, and so did the install check on ubuntu-latest, macos-latest and windows-latest with Python 3.14.8, and on ubuntu-latest with Python 3.9.25. Both uploads were skipped, as on every branch. -**[placeholder: the date Brett Heap makes the pending trusted publishers on -pypi.org and test.pypi.org.]** +Brett Heap added the pending trusted publishers on pypi.org and +test.pypi.org, and said so at 2026-10-16T13:04:43Z ("the pypi are added"). +At 13:07:56Z he confirmed that "they match what you listed": each names the +project `openreposhape`, the owner `opensoft`, the repository +`openRepoShape` and the workflow `release.yml`, with the environment `pypi` +on pypi.org and `testpypi` on test.pypi.org. 1. **The release commit passes `tests` on itself**, in a `push` or `workflow_dispatch` run of `tests.yml` whose head is that commit. The @@ -507,11 +653,16 @@ by moving a tag. works only once 1.1.0 is on PyPI. #590 (the package, `fe237ec`) and #599 (the release workflow, `32c22b0`) have landed. - **[placeholder: re-read at tag time.] Open `v1.1.0` milestone issues at - main `879d804`, 12:43Z (28 open, 76 closed):** - - with a pull request open: #563 (#630), #600 (#628), #602 (#621), #606 - (#627), #610 (#634), #611 (#639), #647 (#657), #649 (#656); - - with none: #388, #389, #417, #425, #645, #646, #648, #650, #651, #652, - #653, #654, #655, #658, #659, #660, #661, #662, #663, #664. + main `de92614`, 17:16Z (52 open, 83 closed):** + - with a pull request open: #388 (#720), #389 (#722), #417 (#718), #425 + (#717), #563 (#630), #610 (#634), #645 (#713), #646 (#670), #648 + (#682), #650 (#719), #651 (#725), #653 (#727), #655 (#721), #658 + (#699), #659 (#671), #663 (#723), #664 (#706), #665 (#724), #667 + (#726), #668 (#700), #669 and #673 (#705), #677 (#702), #678 (#701), + #696 (#703); + - with none: #652, #654, #660, #661, #662, #666, #672, #674, #676, #680, + #681, #683, #684, #685, #686, #687, #688, #689, #690, #691, #692, #693, + #694, #695, #697, #704, #708. Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone. From d2d3d76256f261486112eb224c714c5f25108854 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 10:31:11 +0000 Subject: [PATCH 52/58] README.md, the handbook and the v1.1.0 notes: the doctor's exit 3 names every reason THIS checkout cannot answer, and the notes are current to main bf1f099 with the 25 pull requests since de92614 (#563, #683) - W683 and RV-f's N4 on #742: README's exit-3 list (Keeping a project's shape current, :1208-1220) and its handbook twin (`doctor`) named only a pin whose commit this checkout does not carry. They now say, as shape-doctor.py's EXIT CODES does: a pin's commit it lacks, a partial clone missing what is compared (#683), a read that fails (`update-upstream-unreadable`), a crashed validator of its own (#278, #410), or its own naming policy not loading (#678). +1 line, with tests/readme-cap.d/683.txt; README.md is 1474, its cap (1422 + 2 + 5 + 12 + 22 + 10 + 1). The two `Keeping a project's shape current` markers move: `doctor` text and marker, `current` marker only. - The notes, "What changed since v1.0.1", one entry per pull request from `git log --first-parent de92614..bf1f099`, each from its squash commit and the issues GitHub says it closes: - adopt-project.py: #699, #719, #749, #727, #717, #728 (with #621's hint), #743 + #720 + #718, #722, #721; - update-shape.py, bump-leg.py and a project's own scripts: #702 (with the pinned validators), #634; - scaffold, setup, family and the shared readers: #725, #671, #744; - shape-doctor.py: #742, #701, #737, #751, #736, #734, #700 + #705, #740; - install, CI and the docs: the exit-3 sentence above. - Counts (99 pull requests since bfed143; 109 of 135 milestone issues closed at 10:28Z), the "current to main" sha and the open list (26) are at main bf1f099, still placeholders for tag time. Left for round B: lane 5's eight lines (#630 issuecomment-6227129879), whose pull requests #726, #747, #682, #724, #746, #745 and #738 are open drafts. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 +++--- docs/handbook.html | 9 +-- docs/release-notes/v1.1.0.md | 132 ++++++++++++++++++++++++++++++----- tests/readme-cap.d/683.txt | 11 +++ 4 files changed, 142 insertions(+), 31 deletions(-) create mode 100644 tests/readme-cap.d/683.txt diff --git a/README.md b/README.md index 354cfa45..fe2495fc 100644 --- a/README.md +++ b/README.md @@ -1207,16 +1207,17 @@ one verdict, and its exit code: Exit 3 is usage or environment (a flag argparse rejects is exit 2) — nothing about it is a statement about the tree you pointed at: no such path, a refused -`--placement-plan`, a `shape-doctor.py` not sitting in a -checkout of the standard, or a pin naming a commit **this** checkout does not -carry (`CANNOT ANSWER`). **Drift outranks a red validator** on the verdict -line, deliberately — an edited shape copy is exactly what makes -`validate-pins.py` red, so `INVALID (pins)` would send the reader at the -symptom while the fix is `update-shape.py`; the pins row still prints its own -next command. `MISPLACED` sits between the two: a path in the wrong leg is a -fact about the shape no validator, pin or manifest here can see. **An -unfinished merge outranks all three**, as `INVALID (merge)`: the conflict is -what turns the other rows red, and no fix commits until it is finished. +`--placement-plan`, a `shape-doctor.py` outside a checkout of the standard, or +**this** checkout unable to answer (`CANNOT ANSWER`): a pin's commit it lacks, a +partial clone missing what is compared, a read that fails, a crashed validator +of its own, or its own naming policy not loading. **Drift outranks a red +validator** on the verdict line, deliberately — an edited shape copy is exactly +what makes `validate-pins.py` red, so `INVALID (pins)` would send the reader at +the symptom while the fix is `update-shape.py`; the pins row still prints its +own next command. `MISPLACED` sits between the two: a path in the wrong leg is a +fact about the shape no validator, pin or manifest here can see. **An unfinished +merge outranks all three**, as `INVALID (merge)`: the conflict is what turns the +other rows red, and no fix commits until it is finished. `--json` is the same report as one object on stdout, each row carrying its `id`. **`placement` is the row that reads what is *inside* the legs.** Brett Heap, diff --git a/docs/handbook.html b/docs/handbook.html index b193e1d6..6096c5dd 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1531,7 +1531,7 @@

    Bootstrap is COPIED into the project, not fetched

    - +

    Reference

    Keeping a project’s shape current

    @@ -1593,7 +1593,7 @@

    Keeping a project’s shape current

    - +

    Reference

    Is this repository compliant? openRepoShape --doctor

    @@ -1656,8 +1656,9 @@

    Is this repository compliant? openRepoShape --doctor

    Exit 3 is usage or environment (a flag argparse rejects is exit 2) — nothing about it is a statement about the tree you pointed at: no such path, a refused --placement-plan, a shape-doctor.py not in a checkout of the - standard, or a pin naming a commit this checkout does not carry - (CANNOT ANSWER). Drift outranks a red validator on the + standard, or this checkout unable to answer (CANNOT ANSWER): a + pin’s commit it lacks, a partial clone missing what is compared, a read that fails, a + crashed validator of its own, or its own naming policy not loading. Drift outranks a red validator on the verdict line, deliberately: an edited shape copy is exactly what makes validate-pins.py red, so answering INVALID (pins) would send the reader at the symptom while the fix is update-shape.py. The pins row is printed diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index fc709272..fc26ceea 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -11,9 +11,9 @@ PowerShell cannot run the one line (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At main `de92614`, on 2026-10-16, -74 pull requests had landed on main since v1.0.0's commit, `bfed143`; at -17:16Z, 83 of the `v1.1.0` milestone's 135 issues were closed. +**[placeholder: the counts at tag time.]** At main `bf1f099`, on 2026-10-17, +99 pull requests had landed on main since v1.0.0's commit, `bfed143`; at +10:28Z, 109 of the `v1.1.0` milestone's 135 issues were closed. ## What 1.1.0 adds @@ -110,8 +110,8 @@ CI on Linux only (#597). v1.0.1 was cut from `release/v1.0` and added only the one-line install. Everything below landed on main after v1.0.0's commit, by area. The list -is current to main `de92614`. **[placeholder: the pull requests that land -after main `de92614` and before the release commit.]** +is current to main `bf1f099`. **[placeholder: the pull requests that land +after main `bf1f099` and before the release commit.]** ### adopt-project.py @@ -176,11 +176,50 @@ after main `de92614` and before the release commit.]** `--project`, and `adopt-bad-id` now says so: `--project North_Wind` no longer says `--id 'north_wind' must match` over a flag nobody passed (#620, #601). +- **Empty and derived values.** `plan` refuses `--id ''` and + `--elected-by ''` by name, a refused derived id says to change + `--project` (or, adopting in place, to rename the source first), and an + organisation read from a url is named as not given (#699, #658). +- **`--work-dir`.** A `--work-dir` that is a file or cannot be made is + refused by name in `plan`, `check` and `execute`, and `--help` says what + `--work-dir` is and where `--org` comes from (#719, #650). +- **`--source`, by its spelling.** An `org/repo` in any spelling is cloned + by its name, the plan records `source.cloned`, and a clone no plan + records is removed, so a recorded clone that is gone or emptied is + refused, or cloned again, for what it is (#749, #652). +- **Names in a C locale.** Under `LC_ALL=C PYTHONUTF8=0`, `plan`, `check` + and `execute` hand git and `gh` each name, url and branch as the UTF-8 + bytes it spells, so none of them ends in a `UnicodeEncodeError` before + any leg exists (#727, #653). +- **A default branch that is not UTF-8.** A source whose HEAD or + `origin/HEAD` is on a branch such as `caf` is refused, or adopted past + it, instead of ending `plan`, `check` and `execute` in a traceback (#717, + #425). - **The Free-plan secrets hint.** `adopt-project.py` and `scaffold-project.py` go by the assembly root's own visibility on GitHub and name the root as the reason, so a public source adopted with private legs is no longer told that its organisation secrets resolve to EMPTY - (#621, #602). + (#621, #602). The hint, in the scaffold, the adoption and `family.py`, + goes by what GitHub says of the root or holder and of its organisation's + plan, and names any answer GitHub did not give, so + `init --reuse-empty-repo` no longer calls a public holder private (#728, + #660). +- **Caught by `check`, not after both legs.** `check` asks this machine's + git what the split stores for each file the plan keeps or moves and each + pinned copy, so one it would store as other bytes is a finding (#743, + #654). A source symlink whose target begins with a NUL byte is + `plan-empty-symlink`, which `execute` refuses before any leg (#720, + #388). And `check --local-remote-dir` resolves each relative submodule url + a leg keeps from the bare repositories `execute --local-remote-dir` would + create, so the test path no longer disagrees with `execute` (#718, #417). +- **`adoption verified` means a shape root.** The verification fails a + split whose root `project.yaml` is not the shape's manifest + (`adopt-manifest-not-shape`), so it no longer passes an assembly the + doctor calls NOT A SHAPE ROOT (#722, #389). +- **Answers that stay answered.** A plan's answer against a rule is + recorded, and an empty `.gitmodules` asks nothing, so `docs/` answered + `code` is not MISPLACED after `adoption verified`, nor is a `.gitmodules` + emptied by `git rm` asked on every run (#721, #655). - **Symlinks checked out as files.** `check`'s finding for a link kept above a path the shape writes says that the clone holds it as a file (#584, #531). @@ -231,7 +270,14 @@ after main `de92614` and before the release commit.]** - **The pinned validators.** THE LOCKSTEP RULE is printed only where a leg or member is out of step. A lone digest or workflow-reference mismatch names its `bump-leg.py` line, and a holder's remedies name the branch to - make first (#575, #539). + make first (#575, #539). One leg's gitlink off its pin, beside another + leg's digest or workflow-reference mismatch, names the step that comes + first: put that gitlink back by hand, then run the other leg's + `bump-leg.py` line (#702, #677). +- **The pinned shape copies, in one bump.** `AGENTS-shape.md` is true of + every root, the scaffold's chain WARNING offers only its own sources and + spells `--pin-source`, the root's not-used note spells it too, and THE + LOCKSTEP RULE's `bump` runs as printed (#634, #610). - **A workflows directory the pins gate cannot read.** `validate-pins.py` refuses a `.github/workflows/` directory this user cannot list or search as `workflow-unreadable`, naming it, exit 2, where it ended in a @@ -274,6 +320,15 @@ after main `de92614` and before the release commit.]** - **`--pin-source`.** A pin file records `digest_source: local-clone`, never the path, in the scaffold and in `adopt-project.py`, and a `--pin-source` nothing reads is refused `pin-source-unused` (#570, #540, #451, #450). +- **A `--pin` naming a tag.** In `scaffold-project.py` and + `adopt-project.py`, a `--pin` that names an annotated tag's own id pins + the commit the tag names, and one that names a tree or a blob is refused, + so a pin file no longer records a tag's id as its commit (#725, #651). +- **A derived id, named as derived.** `scaffold-bad-id` and + `family-bad-id` name an id derived from `--project` or `--family` as + derived and say to change that flag, so `--project North_Wind` no longer + sends the person to an `--id` the naming policy then refuses (#671, + #659). - **setup-project.py.** Its paste lines quote each path, the scaffold's NEXT STEPS wait for step (7), step (3) refuses a file above the clone, `-- --dry-run` ends at the plan, step (4) hears the pins passed to it, and @@ -286,6 +341,11 @@ after main `de92614` and before the release commit.]** - **Pin chains.** A bare `--link-source` is the tree of the chain's first link alone, and a source that names no path is refused (#576, #441, #442). +- **A chain link no tree answers for.** The root's `validate-manifest.py`, + the naming gate and setup's step (4) each offer what that caller reads, + so neither the validator nor setup offers `--link-source`, which neither + takes, and the gate spells every `--link-source` value and path it prints + (#744, #661). - **One product, one spelling.** A chain link, a declared pin and a `--link-source` key name one product exactly when they share a pin file, so a tree declaring `openss` no longer verifies a chain that says @@ -365,6 +425,43 @@ after main `de92614` and before the release commit.]** only from a folder named after the holder's repository (#624, #605). - **The `machine` row** of a machine that is not ready names the preflight checks that failed (#552, #549). +- **A partial clone of the standard.** Run from one, the `shape currency` + row asks, without fetching, whether the clone holds what the comparison + reads, and where it does not it is CANNOT ANSWER, exit 3, with the fetch + for a person to run; `update-shape.py` refuses a read that fails + (`update-upstream-unreadable`), never calling the copy `both` (#742, + #683). +- **A naming policy that does not load.** A checkout whose own + `contracts/repository-naming.yaml` does not load is CANNOT ANSWER, exit 3, + where it was `INVALID (naming)`, exit 1, and the EXIT CODES list names an + unfinished merge (#701, #678). +- **A root that lost `repo_shape.py`, named.** An own-copy validator row + whose crash is that loss names `git -C checkout HEAD -- + scripts/repo_shape.py` as its NEXT, or none where HEAD no longer holds it + (#737, #697). +- **The `pins` row and REFUSAL AHEAD.** The row keeps its gate where + `update-shape.py check` says REFUSAL AHEAD beside a merged-away row, so it + no longer says the `apply` that `check` prints drops the row when that + `apply` is refused, and the `shape currency` counts name every state + `check` counts (#751, #676). +- **A conflicted pin, both sides read.** A conflicted shape pin's NEXT reads + both sides' pins, so a copy that only the side not taken has a `files:` + row for is named for a person to merge, and never taken (#736, #687). +- **`placement` and a leg's `single-repository.yaml`.** The row asks a + leg's top-level `single-repository.yaml` the staying-single record's + question only where `kind:` makes it the record, as `plan` asks it + (#734, #691). +- **A family holder's `.gitmodules`.** The doctor reads it through one + `git config -z --list`, so a section name holding a space or a byte that + is not UTF-8, or a url holding NEL or U+2028, gives the mount url git and + `make siblings` use, under any locale (#700, #668). The `members` row + says, member by member, what `make siblings` does with the directory + beside the holder, and spells every value it quotes, so an ESC or a CR in + a url no longer reaches the terminal (#705, #669, #673). +- **A signal to `openRepoShape --doctor`.** A signal sent to the command's + shell alone waits for the doctor, so its clone of the standard is never + removed while `shape-doctor.py` runs from it, and a doctor ended by a + signal leaves nothing behind (#740, #680). ### Install, CI and the docs @@ -388,6 +485,10 @@ after main `de92614` and before the release commit.]** by-hand setup commands dry-run the scaffold with the `--visibility` and `--elected-by` setup passes, and `apply --add`'s five refusals are listed by their codes (#579, #550, #429). +- **The doctor's exit 3, listed whole.** README.md and the handbook name + every reason the doctor cannot answer: a pin's commit the checkout lacks, + a partial clone missing what is compared, a read that fails, a crashed + validator of its own, and its own naming policy not loading (#630, #683). - **Three sentences made true.** README.md and the handbook say that a source's own `single-repository.yaml` record arrives as `leg: drop`, that the adopted Makefile's `CONTRACTS_DIR` follows the spec leg's mount, and @@ -653,16 +754,13 @@ by moving a tag. works only once 1.1.0 is on PyPI. #590 (the package, `fe237ec`) and #599 (the release workflow, `32c22b0`) have landed. - **[placeholder: re-read at tag time.] Open `v1.1.0` milestone issues at - main `de92614`, 17:16Z (52 open, 83 closed):** - - with a pull request open: #388 (#720), #389 (#722), #417 (#718), #425 - (#717), #563 (#630), #610 (#634), #645 (#713), #646 (#670), #648 - (#682), #650 (#719), #651 (#725), #653 (#727), #655 (#721), #658 - (#699), #659 (#671), #663 (#723), #664 (#706), #665 (#724), #667 - (#726), #668 (#700), #669 and #673 (#705), #677 (#702), #678 (#701), - #696 (#703); - - with none: #652, #654, #660, #661, #662, #666, #672, #674, #676, #680, - #681, #683, #684, #685, #686, #687, #688, #689, #690, #691, #692, #693, - #694, #695, #697, #704, #708. + main `bf1f099`, 2026-10-17T10:28Z (26 open, 109 closed):** + - with a pull request open: #563 (#630), #645 (#713), #646 (#670), #648 + (#682), #663 (#723), #664 (#706), #665 (#724), #666 (#730), #667 + (#726), #672 (#746), #681 (#747), #692 (#731), #694 and #704 (#745), + #695 (#738), #696 (#703); + - with none: #662, #674, #684, #685, #686, #688, #689, #690, #693, + #708. Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone. diff --git a/tests/readme-cap.d/683.txt b/tests/readme-cap.d/683.txt new file mode 100644 index 00000000..4b4a2094 --- /dev/null +++ b/tests/readme-cap.d/683.txt @@ -0,0 +1,11 @@ ++1 +2026-10-17 +One line for the doctor's exit 3 (#683, landed in #742, and RV-f's N4 on +it): README's list of what makes `shape-doctor.py` exit 3 named only a pin +whose commit this checkout does not carry, where the doctor's own EXIT +CODES list, which #742 extended, says THIS CHECKOUT cannot answer for four +more reasons: a partial clone lacking what the comparison reads (#683), a +read of it that fails, a crashed validator of its own (#278, #410) and its +own naming policy not loading (#678). A reader who gets `CANNOT ANSWER` +from a partial clone looks the exit up here, and the list now has it. The +sentence was reworded to cost one line, not three. From b2a62ec79c0f99b9e98c647a2418cd9ca5f6e6fe Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 11:55:03 +0000 Subject: [PATCH 53/58] README.md, the handbook and the v1.1.0 notes: lane 2's review of round A3 (three SHOULDs, four nits), and notes entries for the seven pull requests landed since bf1f099b, current to main 2000488 (#563, #683) Round A4, from lane 2's review (#630 issuecomment-6233033833, READY): - SHOULD 1: the doctor's exit-3 list names "a validator of its own that crashed or could not be run", so the case of one that cannot be given its `PYTHONPATH` (shape-doctor.py's EXIT CODES, #278) is listed too: README.md:1208-1222, its handbook twin (`doctor`) and the notes' docs line. The paragraph re-wraps to one more line: tests/readme-cap.d/683.txt is +2, and its text now says which causes the EXIT CODES block names and that #742 added the partial clone and #701 the naming policy. README.md is 1475 lines, its cap (1422 + 2 + 5 + 12 + 22 + 10 + 2). Both `Keeping a project's shape current` markers move to c728c970: `doctor` text and marker, `current` marker only. - SHOULD 2: #727's entry names the leg description, whose em dash stopped every `execute` against GitHub at the first leg. - SHOULD 3: #749's entry names `source-ambiguous`, for an `org/repo` that is also a directory here. - Nits: the doctor section's three near-duplicate pairs are merged (#636 with #737, #632 with #700 and #705, #586 with #736); #718 has its own bullet; #634's entry names `git_out`'s spelling for every caller and #744's the gate's `--link-source-label` and `--link-remedy`; the header comment says 2026-10-17 at main 2000488. Wave B1, `git log --first-parent bf1f099b..20004887`: #706 (#664), #703 (#696), #670 (#646), #682 (#648), #726 (#667), #730 (#666) and #724 (#665), each from its squash commit; #726's and #682's from lane 5's lines (#630 issuecomment-6227129879), #682's with the unmerged `.gitmodules` its last round added, and #724's with lane 5's note that v1.0.0's test-hygiene line on the generator's `.pyc` files closes. The counts (106 pull requests; 116 of 135 milestone issues closed at 11:53Z), "current to main" and the open list (19) are at main 2000488, still placeholders. Co-Authored-By: Claude Opus 5.5 --- README.md | 19 ++--- docs/handbook.html | 7 +- docs/release-notes/v1.1.0.md | 133 ++++++++++++++++++++++------------- tests/readme-cap.d/683.txt | 22 +++--- 4 files changed, 110 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 7fbbd5c1..07355499 100644 --- a/README.md +++ b/README.md @@ -1209,15 +1209,16 @@ Exit 3 is usage or environment (a flag argparse rejects is exit 2) — nothing a it is a statement about the tree you pointed at: no such path, a refused `--placement-plan`, a `shape-doctor.py` outside a checkout of the standard, or **this** checkout unable to answer (`CANNOT ANSWER`): a pin's commit it lacks, a -partial clone missing what is compared, a read that fails, a crashed validator -of its own, or its own naming policy not loading. **Drift outranks a red -validator** on the verdict line, deliberately — an edited shape copy is exactly -what makes `validate-pins.py` red, so `INVALID (pins)` would send the reader at -the symptom while the fix is `update-shape.py`; the pins row still prints its -own next command. `MISPLACED` sits between the two: a path in the wrong leg is a -fact about the shape no validator, pin or manifest here can see. **An unfinished -merge outranks all three**, as `INVALID (merge)`: the conflict is what turns the -other rows red, and no fix commits until it is finished. +partial clone missing what is compared, a read that fails, a validator of its +own that crashed or could not be run, or its own naming policy not loading. +**Drift outranks a red validator** on the verdict line, deliberately — an edited +shape copy is exactly what makes `validate-pins.py` red, so `INVALID (pins)` +would send the reader at the symptom while the fix is `update-shape.py`; the +pins row still prints its own next command. `MISPLACED` sits between the two: a +path in the wrong leg is a fact about the shape no validator, pin or manifest +here can see. **An unfinished merge outranks all three**, as `INVALID (merge)`: +the conflict is what turns the other rows red, and no fix commits until it is +finished. `--json` is the same report as one object on stdout, each row carrying its `id`. **`placement` is the row that reads what is *inside* the legs.** Brett Heap, diff --git a/docs/handbook.html b/docs/handbook.html index 6c727f7d..5d84e4ae 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1531,7 +1531,7 @@

    Bootstrap is COPIED into the project, not fetched

    - +

    Reference

    Keeping a project’s shape current

    @@ -1593,7 +1593,7 @@

    Keeping a project’s shape current

    - +

    Reference

    Is this repository compliant? openRepoShape --doctor

    @@ -1658,7 +1658,8 @@

    Is this repository compliant? openRepoShape --doctor

    --placement-plan, a shape-doctor.py not in a checkout of the standard, or this checkout unable to answer (CANNOT ANSWER): a pin’s commit it lacks, a partial clone missing what is compared, a read that fails, a - crashed validator of its own, or its own naming policy not loading. Drift outranks a red validator on the + validator of its own that crashed or could not be run, or its own naming policy not + loading. Drift outranks a red validator on the verdict line, deliberately: an edited shape copy is exactly what makes validate-pins.py red, so answering INVALID (pins) would send the reader at the symptom while the fix is update-shape.py. The pins row is printed diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index fc26ceea..6b8c0a2a 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -1,7 +1,7 @@ # openRepoShape v1.1.0 @@ -11,9 +11,9 @@ PowerShell cannot run the one line (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At main `bf1f099`, on 2026-10-17, -99 pull requests had landed on main since v1.0.0's commit, `bfed143`; at -10:28Z, 109 of the `v1.1.0` milestone's 135 issues were closed. +**[placeholder: the counts at tag time.]** At main `2000488`, on 2026-10-17, +106 pull requests had landed on main since v1.0.0's commit, `bfed143`; at +11:53Z, 116 of the `v1.1.0` milestone's 135 issues were closed. ## What 1.1.0 adds @@ -110,8 +110,8 @@ CI on Linux only (#597). v1.0.1 was cut from `release/v1.0` and added only the one-line install. Everything below landed on main after v1.0.0's commit, by area. The list -is current to main `bf1f099`. **[placeholder: the pull requests that land -after main `bf1f099` and before the release commit.]** +is current to main `2000488`. **[placeholder: the pull requests that land +after main `2000488` and before the release commit.]** ### adopt-project.py @@ -183,14 +183,16 @@ after main `bf1f099` and before the release commit.]** - **`--work-dir`.** A `--work-dir` that is a file or cannot be made is refused by name in `plan`, `check` and `execute`, and `--help` says what `--work-dir` is and where `--org` comes from (#719, #650). -- **`--source`, by its spelling.** An `org/repo` in any spelling is cloned - by its name, the plan records `source.cloned`, and a clone no plan - records is removed, so a recorded clone that is gone or emptied is - refused, or cloned again, for what it is (#749, #652). +- **`--source`, by its spelling.** An `org/repo` that is also a directory + here is refused `source-ambiguous`, naming both; any other `org/repo`, in + any spelling, is cloned by its name. The plan records `source.cloned`, + and a clone no plan records is removed, so a recorded clone that is gone + or emptied is refused, or cloned again, for what it is (#749, #652). - **Names in a C locale.** Under `LC_ALL=C PYTHONUTF8=0`, `plan`, `check` and `execute` hand git and `gh` each name, url and branch as the UTF-8 - bytes it spells, so none of them ends in a `UnicodeEncodeError` before - any leg exists (#727, #653). + bytes it spells, and the leg description, whose em dash stopped every + `execute` against GitHub at the first leg, so none of them ends in a + `UnicodeEncodeError` before any leg exists (#727, #653). - **A default branch that is not UTF-8.** A source whose HEAD or `origin/HEAD` is on a branch such as `caf` is refused, or adopted past it, instead of ending `plan`, `check` and `execute` in a traceback (#717, @@ -209,9 +211,12 @@ after main `bf1f099` and before the release commit.]** pinned copy, so one it would store as other bytes is a finding (#743, #654). A source symlink whose target begins with a NUL byte is `plan-empty-symlink`, which `execute` refuses before any leg (#720, - #388). And `check --local-remote-dir` resolves each relative submodule url - a leg keeps from the bare repositories `execute --local-remote-dir` would - create, so the test path no longer disagrees with `execute` (#718, #417). + #388). +- **`check --local-remote-dir`.** `check` takes `--local-remote-dir` and + resolves each relative submodule url a leg keeps from the bare + repositories `execute --local-remote-dir` would create, so under that test + path `check` no longer refuses an adoption `execute` verifies, nor passes + one `execute` refuses (#718, #417). - **`adoption verified` means a shape root.** The verification fails a split whose root `project.yaml` is not the shape's manifest (`adopt-manifest-not-shape`), so it no longer passes an assembly the @@ -276,8 +281,10 @@ after main `bf1f099` and before the release commit.]** `bump-leg.py` line (#702, #677). - **The pinned shape copies, in one bump.** `AGENTS-shape.md` is true of every root, the scaffold's chain WARNING offers only its own sources and - spells `--pin-source`, the root's not-used note spells it too, and THE - LOCKSTEP RULE's `bump` runs as printed (#634, #610). + spells `--pin-source`, the root's not-used note spells it too, THE + LOCKSTEP RULE's `bump` runs as printed, and `repo_shape.py`'s `git_out` + spells the folder it names and git's stderr (ESC, CR, BEL, C1 controls) + for every caller (#634, #610). - **A workflows directory the pins gate cannot read.** `validate-pins.py` refuses a `.github/workflows/` directory this user cannot list or search as `workflow-unreadable`, naming it, exit 2, where it ended in a @@ -344,8 +351,9 @@ after main `bf1f099` and before the release commit.]** - **A chain link no tree answers for.** The root's `validate-manifest.py`, the naming gate and setup's step (4) each offer what that caller reads, so neither the validator nor setup offers `--link-source`, which neither - takes, and the gate spells every `--link-source` value and path it prints - (#744, #661). + takes, and the gate spells every `--link-source` value and path it prints. + The naming gate takes `--link-source-label` and `--link-remedy`, so setup + passes its own flag and words for a holder (#744, #661). - **One product, one spelling.** A chain link, a declared pin and a `--link-source` key name one product exactly when they share a pin file, so a tree declaring `openss` no longer verifies a chain that says @@ -388,7 +396,23 @@ after main `bf1f099` and before the release commit.]** on a name such as `"a\ud83db"` or on a dash under `PYTHONIOENCODING=ascii`. The holder's README names its folder after the repository, so `--family Quill --name QuillWorks` lays out as its README - says (#642, #454, #629). + says (#642, #454, #629). `setup-project.py` names a family folder after + the holder's repository too, so the `Quill/Quill` that `family.py init + --name QuillWorks` makes is detected with no flag (#703, #696). +- **A member's gitlink under another spelling.** Where a member's only + gitlink sits under another spelling of its row's path, `family.py add`'s + refusal moves it back to that path in the index alone, so the printed + remedy no longer takes the member's gitlink away from its row (#670, + #646). +- **A holder's `make bootstrap` that checked nothing out.** It no longer + says `family bootstrap ok` over a clone that left a member unchecked-out: + a subset clone, a member with `update = none`, a gitlink git stops at (a + stray `Members/

    ` beside `members/

    `, a member mounted by hand with no + row, a `.gitmodules` section with no url), a `.gitmodules` that git reads + otherwise than `git config` does (two sections for one path, a section + name git ignores, a value beginning with `-`), and a `.gitmodules` a merge + left unmerged now fail, and the line names the cause and the cure. This + changes an exit code a script can see: ok became exit 1 (#682, #648). ### shape-doctor.py @@ -409,11 +433,19 @@ after main `bf1f099` and before the release commit.]** ignores site-packages, so a `.pth` file in the user's or a virtualenv's site-packages no longer stands in for the `scripts/repo_shape.py` a root lost: the `manifest` row is the gate's own `ModuleNotFoundError`, as in - CI (#636, #342). -- **A holder's `.gitmodules` that is not UTF-8.** `--root` on a family - holder reads a member url that is not UTF-8 as `make siblings` does, so a - member mounted from a legacy-code-page directory gets a verdict, where the - doctor ended in a `UnicodeDecodeError` traceback, exit 1 (#632, #432). + CI (#636, #342). An own-copy validator row whose crash is that loss names + `git -C checkout HEAD -- scripts/repo_shape.py` as its NEXT, or + none where HEAD no longer holds it (#737, #697). +- **A family holder's `.gitmodules`.** `--root` on a holder reads a member + url that is not UTF-8 as `make siblings` does, so a member mounted from a + legacy-code-page directory gets a verdict, where the doctor ended in a + `UnicodeDecodeError` traceback, exit 1 (#632, #432). It reads the file + through one `git config -z --list`, so a section name holding a space or a + byte that is not UTF-8, or a url holding NEL or U+2028, gives the mount + url git and `make siblings` use, under any locale (#700, #668). The + `members` row says, member by member, what `make siblings` does with the + directory beside the holder, and spells every value it quotes, so an ESC + or a CR in a url no longer reaches the terminal (#705, #669, #673). - **NEXT lines.** The `legs` and `members` NEXT names every detached commit it would move, and sends a mount with no `.git`, an unborn HEAD or no url to a line that cures it, or to none (#559, #413, #414). `manifest kinds` @@ -421,7 +453,9 @@ after main `bf1f099` and before the release commit.]** path its gate reads (#555, #548, #439). A conflicted shape pin's NEXT takes whole each copy both sides hold as the standard wrote it, and a leg pin put back from before its bump says to commit it and run the `pins` row's line - (#586, #547). The `members` row says `make siblings` places the clones + (#586, #547); it reads both sides' pins, so a copy that only the side not + taken has a `files:` row for is named for a person to merge, and never + taken (#736, #687). The `members` row says `make siblings` places the clones only from a folder named after the holder's repository (#624, #605). - **The `machine` row** of a machine that is not ready names the preflight checks that failed (#552, #549). @@ -435,29 +469,15 @@ after main `bf1f099` and before the release commit.]** `contracts/repository-naming.yaml` does not load is CANNOT ANSWER, exit 3, where it was `INVALID (naming)`, exit 1, and the EXIT CODES list names an unfinished merge (#701, #678). -- **A root that lost `repo_shape.py`, named.** An own-copy validator row - whose crash is that loss names `git -C checkout HEAD -- - scripts/repo_shape.py` as its NEXT, or none where HEAD no longer holds it - (#737, #697). - **The `pins` row and REFUSAL AHEAD.** The row keeps its gate where `update-shape.py check` says REFUSAL AHEAD beside a merged-away row, so it no longer says the `apply` that `check` prints drops the row when that `apply` is refused, and the `shape currency` counts name every state `check` counts (#751, #676). -- **A conflicted pin, both sides read.** A conflicted shape pin's NEXT reads - both sides' pins, so a copy that only the side not taken has a `files:` - row for is named for a person to merge, and never taken (#736, #687). - **`placement` and a leg's `single-repository.yaml`.** The row asks a leg's top-level `single-repository.yaml` the staying-single record's question only where `kind:` makes it the record, as `plan` asks it (#734, #691). -- **A family holder's `.gitmodules`.** The doctor reads it through one - `git config -z --list`, so a section name holding a space or a byte that - is not UTF-8, or a url holding NEL or U+2028, gives the mount url git and - `make siblings` use, under any locale (#700, #668). The `members` row - says, member by member, what `make siblings` does with the directory - beside the holder, and spells every value it quotes, so an ESC or a CR in - a url no longer reaches the terminal (#705, #669, #673). - **A signal to `openRepoShape --doctor`.** A signal sent to the command's shell alone waits for the doctor, so its clone of the standard is never removed while `shape-doctor.py` runs from it, and a doctor ended by a @@ -471,11 +491,26 @@ after main `bf1f099` and before the release commit.]** drive-letter `$OPENREPOSHAPE_REPO` is read as a path, a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` is respelled or refused, and the `tests-git-bash` job runs `install.sh`'s cases from #598 too (#656, #649). +- **`$OPENREPOSHAPE_BIN_DIR` on Windows.** `install.sh` respells a + Windows-spelled `$OPENREPOSHAPE_BIN_DIR`, or refuses it, before it fetches + anything; `--install` refuses one holding `:` or a newline, where it + placed the command and printed a line that cannot be used; and the + `export PATH=...` note single-quotes a directory holding a double quote, + a `$`, a backquote, a backslash or `!` (#726, #667). - **CI** on Linux only (#597), with a result for every landing on main (#593, #592), and a Git Bash job on Windows (#596, #585). Every test that clones a just-pushed local remote now clones it in a way git's background maintenance cannot race (#554, #544), and the test that holds the doctor's NEXT lines to no `#` comment tail reads each string whole (#698, #679). + Tests now fail if `validate-pins.py`'s workflow scan reads a directory, a + dangling symlink or a looping symlink named `.yml`, or if `siblings.py`'s + headers print the holder's root and folder raw (#706, #664). + `scripts/render-cli-reference.py` sets `PYTHONDONTWRITEBYTECODE` for the + `--help` children it runs, so the generator leaves no `.pyc` files in the + template tree, which v1.0.0 listed among its test-hygiene limits (#724, + #665). Four comments and docstrings in `adopt-project.py`, + `shape-doctor.py` and a test say what the code beneath them does (#730, + #666). - **The docs.** README.md and the handbook say how to install from PyPI, first on native Windows, and that the one line installs git-filter-repo or prints the command for the machine (#563), and that Git Bash runs the one @@ -487,8 +522,9 @@ after main `bf1f099` and before the release commit.]** their codes (#579, #550, #429). - **The doctor's exit 3, listed whole.** README.md and the handbook name every reason the doctor cannot answer: a pin's commit the checkout lacks, - a partial clone missing what is compared, a read that fails, a crashed - validator of its own, and its own naming policy not loading (#630, #683). + a partial clone missing what is compared, a read that fails, a validator + of its own that crashed or could not be run, and its own naming policy + not loading (#630, #683). - **Three sentences made true.** README.md and the handbook say that a source's own `single-repository.yaml` record arrives as `leg: drop`, that the adopted Makefile's `CONTRACTS_DIR` follows the spec leg's mount, and @@ -754,13 +790,12 @@ by moving a tag. works only once 1.1.0 is on PyPI. #590 (the package, `fe237ec`) and #599 (the release workflow, `32c22b0`) have landed. - **[placeholder: re-read at tag time.] Open `v1.1.0` milestone issues at - main `bf1f099`, 2026-10-17T10:28Z (26 open, 109 closed):** - - with a pull request open: #563 (#630), #645 (#713), #646 (#670), #648 - (#682), #663 (#723), #664 (#706), #665 (#724), #666 (#730), #667 - (#726), #672 (#746), #681 (#747), #692 (#731), #694 and #704 (#745), - #695 (#738), #696 (#703); - - with none: #662, #674, #684, #685, #686, #688, #689, #690, #693, - #708. + main `2000488`, 2026-10-17T11:53Z (19 open, 116 closed):** + - with a pull request open: #563 (#630), #645 (#713), #663 (#723), #672 + (#746), #674 (#810), #681 (#747), #684 (#816), #685 (#767), #686 + (#795), #688 (#809), #689 (#813), #690 (#805), #692 (#731), #693 + (#812), #694 and #704 (#745), #695 (#738); + - with none: #662, #708. Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone. diff --git a/tests/readme-cap.d/683.txt b/tests/readme-cap.d/683.txt index 4b4a2094..e0376972 100644 --- a/tests/readme-cap.d/683.txt +++ b/tests/readme-cap.d/683.txt @@ -1,11 +1,13 @@ -+1 ++2 2026-10-17 -One line for the doctor's exit 3 (#683, landed in #742, and RV-f's N4 on -it): README's list of what makes `shape-doctor.py` exit 3 named only a pin -whose commit this checkout does not carry, where the doctor's own EXIT -CODES list, which #742 extended, says THIS CHECKOUT cannot answer for four -more reasons: a partial clone lacking what the comparison reads (#683), a -read of it that fails, a crashed validator of its own (#278, #410) and its -own naming policy not loading (#678). A reader who gets `CANNOT ANSWER` -from a partial clone looks the exit up here, and the list now has it. The -sentence was reworded to cost one line, not three. +Two lines for the doctor's exit 3, taken in #630 for #683 (W683, and RV-f's +N4 on #742, which landed the partial-clone case). README's list of what +makes `shape-doctor.py` exit 3 named only a pin whose commit this checkout +does not carry. shape-doctor.py's own EXIT CODES block names more: a +partial clone lacking what the comparison reads (#683, added by #742), a +validator of its own that crashed or cannot be given its `PYTHONPATH` +(#278, #410), and its own naming policy not loading (#678, added by #701); +and the code refuses a read of the standard that fails +(`update-upstream-unreadable`, #742) as CANNOT ANSWER too. A reader who +gets `CANNOT ANSWER` looks the exit up in this paragraph, and it now lists +every cause. Reworded so the five causes cost two lines. From 6a6817cdcbdbe083869dc76fec88d793da24a32e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 16:17:31 +0000 Subject: [PATCH 54/58] README.md, the handbook and the v1.1.0 notes: RV630's four nits, and notes entries for the five pull requests landed since 20004887, current to main 40e678e (#563, #683) RV630's review of rounds A3, A4 and B1 (#630 issuecomment-6233641684, READY), its four optional nits: - 1: README.md's drift paragraph ended on the one-word line "finished.". "exactly " and "about the shape " are cut, and the paragraph re-wraps to seven lines with both bold leads whole. README.md is 1474 lines, its cap (1422 + 2 + 5 + 12 + 22 + 10 + 1); tests/readme-cap.d/683.txt is +1, and its text says why. Both `Keeping a project's shape current` markers move to df780eb5; the other 21 read current. - 2: #682's lead reads "A holder's `make bootstrap` that left a member unchecked-out". - 3: #730's entry says "Comments and docstrings", not "Four": its squash makes three claims, comments only. - 4: the doctor's `members` row has one bullet, #705's sentence and #624's together. Wave B2 so far, `git log --first-parent 20004887..40e678e6`, each entry from its squash diff: - #713 (#645): the test session turns git's automatic maintenance off (Install, CI and the docs). - #812 (#693): `add --local-remote-dir`'s member clone URL, the remedies and the READMEs' quoted clone URL (the `--local-remote-dir` bullet). - #767 (#685): `members/` renamed with `git mv` is renamed back in two steps, and the git a printed `--sparse` needs (family). - #745 (#704, #694): lane 5's line (#630 issuecomment-6227129879), with v1.0.0's known limit at v1.0.0.md:463 (no git on `PATH`) said closed (Bootstrap). - #805 (#690): `make siblings`'s inputs (family). The counts (111 pull requests; 122 of 135 milestone issues closed at 16:14Z), "current to main" and the open list (13, each with a pull request open) are at main 40e678e, still placeholders. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +++--- docs/handbook.html | 4 +- docs/release-notes/v1.1.0.md | 86 ++++++++++++++++++++++++++---------- tests/readme-cap.d/683.txt | 9 ++-- 4 files changed, 76 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 07355499..f0761220 100644 --- a/README.md +++ b/README.md @@ -1212,13 +1212,12 @@ it is a statement about the tree you pointed at: no such path, a refused partial clone missing what is compared, a read that fails, a validator of its own that crashed or could not be run, or its own naming policy not loading. **Drift outranks a red validator** on the verdict line, deliberately — an edited -shape copy is exactly what makes `validate-pins.py` red, so `INVALID (pins)` -would send the reader at the symptom while the fix is `update-shape.py`; the -pins row still prints its own next command. `MISPLACED` sits between the two: a -path in the wrong leg is a fact about the shape no validator, pin or manifest -here can see. **An unfinished merge outranks all three**, as `INVALID (merge)`: -the conflict is what turns the other rows red, and no fix commits until it is -finished. +shape copy is what makes `validate-pins.py` red, so `INVALID (pins)` would send +the reader at the symptom while the fix is `update-shape.py`; the pins row still +prints its own next command. `MISPLACED` sits between the two: a path in the +wrong leg is a fact no validator, pin or manifest here can see. +**An unfinished merge outranks all three**, as `INVALID (merge)`: the conflict +is what turns the other rows red, and no fix commits until it is finished. `--json` is the same report as one object on stdout, each row carrying its `id`. **`placement` is the row that reads what is *inside* the legs.** Brett Heap, diff --git a/docs/handbook.html b/docs/handbook.html index 5d84e4ae..fadb789d 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1531,7 +1531,7 @@

    Bootstrap is COPIED into the project, not fetched

    - +

    Reference

    Keeping a project’s shape current

    @@ -1593,7 +1593,7 @@

    Keeping a project’s shape current

    - +

    Reference

    Is this repository compliant? openRepoShape --doctor

    diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index 6b8c0a2a..964ca016 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -1,7 +1,7 @@ # openRepoShape v1.1.0 @@ -11,9 +11,9 @@ PowerShell cannot run the one line (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At main `2000488`, on 2026-10-17, -106 pull requests had landed on main since v1.0.0's commit, `bfed143`; at -11:53Z, 116 of the `v1.1.0` milestone's 135 issues were closed. +**[placeholder: the counts at tag time.]** At main `40e678e`, on 2026-10-17, +111 pull requests had landed on main since v1.0.0's commit, `bfed143`; at +16:14Z, 122 of the `v1.1.0` milestone's 135 issues were closed. ## What 1.1.0 adds @@ -110,8 +110,8 @@ CI on Linux only (#597). v1.0.1 was cut from `release/v1.0` and added only the one-line install. Everything below landed on main after v1.0.0's commit, by area. The list -is current to main `2000488`. **[placeholder: the pull requests that land -after main `2000488` and before the release commit.]** +is current to main `40e678e`. **[placeholder: the pull requests that land +after main `40e678e` and before the release commit.]** ### adopt-project.py @@ -267,7 +267,12 @@ after main `2000488` and before the release commit.]** so a commit made there is no longer stranded (#556, #533). A leg on another branch whose tracking branch is behind the pin is fast-forwarded and checked out, and the shipped text says bootstrap places a leg where - that loses no commit (#615, #603). + that loses no commit (#615, #603). `make bootstrap` moves a leg only where + nothing the person has is lost, says which branch it left, and refuses by + name when git cannot run (#745, #704, #694). With no git on `PATH`, both + shipped `bootstrap.py` scripts are refused `git-not-runnable`, exit 2, + where they ended in a `FileNotFoundError` traceback, which v1.0.0 listed + as a known limit. - **A branch name git cannot take.** A `tracking_branch:` or a leg's `branch:` that holds a NUL or a lone surrogate is a `validate-manifest.py` finding, and `make bootstrap` does not hand it to git, so it no longer @@ -314,7 +319,16 @@ after main `2000488` and before the release commit.]** text a README can hold, such as a directory named in a legacy code page, is refused before any remote or folder exists, `--dry-run` alike, where it ended in a `UnicodeEncodeError` traceback after the bare remotes were - made (#640, #433). + made (#640, #433). `family.py add --local-remote-dir` refuses a member + clone URL that the holder's `.gitmodules` cannot hold as text, + `family-unwritable-value`, `--dry-run` alike, before the member is read, + where it committed the URL raw and `make siblings` read another path + back. Each clone-URL remedy says that the URL is the path the flag + resolves to, so the directory to rename may be a link's target, or the + one a relative path was given from, and names a lone surrogate. The + READMEs the scaffold and `family.py init` write quote a local clone URL + for a POSIX shell, so the `git clone` line under `a b/` pastes as one + command (#812, #693). - **Names that are not UTF-8.** In `bump-leg.py`, `scaffold-project.py` and `family.py`, a file name that is not UTF-8 in `git status` is `bump-leg-root-dirty` or the scaffold's DIRTY warning, and a @@ -399,12 +413,34 @@ after main `2000488` and before the release commit.]** says (#642, #454, #629). `setup-project.py` names a family folder after the holder's repository too, so the `Quill/Quill` that `family.py init --name QuillWorks` makes is detected with no flag (#703, #696). +- **`make siblings`'s inputs.** The move it prints to put the holder in a + folder named after the family runs as typed past a staging name + something already holds, and says first to move aside a link to nothing + in its way. A member whose `project:` or url holds a NUL or a lone + surrogate is a finding, `NOT AN ARGUMENT`, in the dry run too, and the + members after it are still placed, where the run ended in a traceback. It + reads the holder's `.gitmodules` through one `git config -z --list`, as + `validate-family.py` and the doctor do, so a url holding NEL or U+2028, + or a section named `members/a=b`, gives the url git uses. Its warning + spells the holder, the folder and the family, and a `tracking_branch:` + that is only a tag of the member's remote is said to leave the clone + DETACHED (#805, #690). - **A member's gitlink under another spelling.** Where a member's only gitlink sits under another spelling of its row's path, `family.py add`'s refusal moves it back to that path in the index alone, so the printed remedy no longer takes the member's gitlink away from its row (#670, #646). -- **A holder's `make bootstrap` that checked nothing out.** It no longer +- **A `members/` renamed with `git mv`.** `family.py add`'s + `family-members-dir-variant` refusal renames it back in two steps through + a name nothing holds, `git mv -- Members members.renaming`, then + `git mv -- members.renaming members`, which takes each checkout and each + section of `.gitmodules` with it on every disk, where the move back left + each section naming the other spelling. It says first to put back what + the disk lacks under it, and to stage, or put back, the work tree's + `.gitmodules`. A printed `git mv --sparse` says it needs git 2.38 or + later, and the move back's `git rm --sparse` git 2.34, so no remedy fails + as typed (#767, #685). +- **A holder's `make bootstrap` that left a member unchecked-out.** It no longer says `family bootstrap ok` over a clone that left a member unchecked-out: a subset clone, a member with `update = none`, a gitlink git stops at (a stray `Members/

    ` beside `members/

    `, a member mounted by hand with no @@ -442,10 +478,12 @@ after main `2000488` and before the release commit.]** `UnicodeDecodeError` traceback, exit 1 (#632, #432). It reads the file through one `git config -z --list`, so a section name holding a space or a byte that is not UTF-8, or a url holding NEL or U+2028, gives the mount - url git and `make siblings` use, under any locale (#700, #668). The - `members` row says, member by member, what `make siblings` does with the - directory beside the holder, and spells every value it quotes, so an ESC - or a CR in a url no longer reaches the terminal (#705, #669, #673). + url git and `make siblings` use, under any locale (#700, #668). +- **The `members` row.** It says, member by member, what `make siblings` + does with the directory beside the holder, and spells every value it + quotes, so an ESC or a CR in a url no longer reaches the terminal (#705, + #669, #673). It says `make siblings` places the clones only from a folder + named after the holder's repository (#624, #605). - **NEXT lines.** The `legs` and `members` NEXT names every detached commit it would move, and sends a mount with no `.git`, an unborn HEAD or no url to a line that cures it, or to none (#559, #413, #414). `manifest kinds` @@ -455,8 +493,7 @@ after main `2000488` and before the release commit.]** back from before its bump says to commit it and run the `pins` row's line (#586, #547); it reads both sides' pins, so a copy that only the side not taken has a `files:` row for is named for a person to merge, and never - taken (#736, #687). The `members` row says `make siblings` places the clones - only from a folder named after the holder's repository (#624, #605). + taken (#736, #687). - **The `machine` row** of a machine that is not ready names the preflight checks that failed (#552, #549). - **A partial clone of the standard.** Run from one, the `shape currency` @@ -502,15 +539,18 @@ after main `2000488` and before the release commit.]** clones a just-pushed local remote now clones it in a way git's background maintenance cannot race (#554, #544), and the test that holds the doctor's NEXT lines to no `#` comment tail reads each string whole (#698, #679). + The test session turns git's automatic maintenance off, in command scope + and in a global config file that includes the person's own, so a test's + copy of a repository it has just committed in or pushed to no longer + meets a repack deleting its loose objects (#713, #645). Tests now fail if `validate-pins.py`'s workflow scan reads a directory, a dangling symlink or a looping symlink named `.yml`, or if `siblings.py`'s headers print the holder's root and folder raw (#706, #664). `scripts/render-cli-reference.py` sets `PYTHONDONTWRITEBYTECODE` for the `--help` children it runs, so the generator leaves no `.pyc` files in the template tree, which v1.0.0 listed among its test-hygiene limits (#724, - #665). Four comments and docstrings in `adopt-project.py`, - `shape-doctor.py` and a test say what the code beneath them does (#730, - #666). + #665). Comments and docstrings in `adopt-project.py`, `shape-doctor.py` + and a test say what the code beneath them does (#730, #666). - **The docs.** README.md and the handbook say how to install from PyPI, first on native Windows, and that the one line installs git-filter-repo or prints the command for the machine (#563), and that Git Bash runs the one @@ -790,12 +830,10 @@ by moving a tag. works only once 1.1.0 is on PyPI. #590 (the package, `fe237ec`) and #599 (the release workflow, `32c22b0`) have landed. - **[placeholder: re-read at tag time.] Open `v1.1.0` milestone issues at - main `2000488`, 2026-10-17T11:53Z (19 open, 116 closed):** - - with a pull request open: #563 (#630), #645 (#713), #663 (#723), #672 - (#746), #674 (#810), #681 (#747), #684 (#816), #685 (#767), #686 - (#795), #688 (#809), #689 (#813), #690 (#805), #692 (#731), #693 - (#812), #694 and #704 (#745), #695 (#738); - - with none: #662, #708. + main `40e678e`, 2026-10-17T16:14Z (13 open, 122 closed), each with a pull + request open:** #563 (#630), #662 (#748), #663 (#723), #672 (#746), #674 + (#810), #681 (#747), #684 (#816), #686 (#795), #688 (#809), #689 (#813), + #692 (#731), #695 (#738), #708 (#817). Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone. diff --git a/tests/readme-cap.d/683.txt b/tests/readme-cap.d/683.txt index e0376972..21670aa4 100644 --- a/tests/readme-cap.d/683.txt +++ b/tests/readme-cap.d/683.txt @@ -1,6 +1,6 @@ -+2 ++1 2026-10-17 -Two lines for the doctor's exit 3, taken in #630 for #683 (W683, and RV-f's +One line for the doctor's exit 3, taken in #630 for #683 (W683, and RV-f's N4 on #742, which landed the partial-clone case). README's list of what makes `shape-doctor.py` exit 3 named only a pin whose commit this checkout does not carry. shape-doctor.py's own EXIT CODES block names more: a @@ -10,4 +10,7 @@ validator of its own that crashed or cannot be given its `PYTHONPATH` and the code refuses a read of the standard that fails (`update-upstream-unreadable`, #742) as CANNOT ANSWER too. A reader who gets `CANNOT ANSWER` looks the exit up in this paragraph, and it now lists -every cause. Reworded so the five causes cost two lines. +every cause. Reworded so the five causes cost two lines, and one of +them given back by cutting "exactly" and "about the shape" from the +drift paragraph that follows, whose last line held one word (RV630's +nit on #630). From cd56ed78439d6ee2df851eebf202deea661a1f60 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 16:43:44 +0000 Subject: [PATCH 55/58] docs/release-notes/v1.1.0.md: RV630's pre-review of the B2 stage, #747's and #817's entries, #642's upgrade note for a holder's `cd` line, and v1.0.0's `--into` limit kept open, current to main 64ec47c (#563) RV630's pre-review of the B2 stage (lane 3's session): - SHOULD-1: the CI bullet said every test that clones a just-pushed local remote clones it safely (#554), but five plain-path `git submodule update --init` runs are left on main (tests/test_doctor_legs_members_next.py:631, :987, :1054; tests/test_family_bump_remove_rollback.py:337, :908). It now says "runs `git clone` on", which is what #554 changed. - NIT-1: #812's READMEs quote a local clone URL "that needs it". - NIT-2: #682's first sentence no longer repeats the lead ("there:"). Landed since 40e678e6, each entry checked against its squash: - #747 (#681), 6626a3c5, whose tree equals `merge-tree 40e678e6 ff7c59b8`: a new family bullet, with RV630's NIT ("`member-gitlink-absent`'s remedy puts the gitlink back by hand"); v1.0.0's limits at v1.0.0.md:466 and :467 are said closed. - #817 (#708), 64ec47c3, whose tree equals `merge-tree 6626a3c5 d63a2673`, and whose own lines equal those of 8fd2fa64, the head RV630 read: RV630's text, appended to the YAML reader bullet. #738 (#695) is v1.1.1 (lane 5, #473 issuecomment-6236564004), so its docs entry is not taken, and v1.0.0's `--into` limit (v1.0.0.md:492) is listed as still open under Known limits. Its upgrade note is true of main since #642 (0ed1b84), so it goes in now, under "Install and upgrade": lane 5's sentence verbatim, and the README's `Y/Y` layout. Counts and the open list at main 64ec47c: 113 pull requests since bfed143; 124 of 135 milestone issues closed at 16:42Z; 11 open, each with a pull request open, #681 and #708 out, #695 marked as planned for v1.1.1. All still placeholders. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/v1.1.0.md | 82 ++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index 964ca016..203d3de6 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -1,7 +1,7 @@ # openRepoShape v1.1.0 @@ -11,9 +11,9 @@ PowerShell cannot run the one line (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At main `40e678e`, on 2026-10-17, -111 pull requests had landed on main since v1.0.0's commit, `bfed143`; at -16:14Z, 122 of the `v1.1.0` milestone's 135 issues were closed. +**[placeholder: the counts at tag time.]** At main `64ec47c`, on 2026-10-17, +113 pull requests had landed on main since v1.0.0's commit, `bfed143`; at +16:42Z, 124 of the `v1.1.0` milestone's 135 issues were closed. ## What 1.1.0 adds @@ -105,13 +105,20 @@ CI on Linux only (#597). [How to pin 1.0.0](v1.0.0.md#how-to-pin-100) with the v1.1.0 release commit. A scaffold run through the package pins that commit and digest by itself. +- **A family holder made before #642.** A holder made with + `--family X --name Y` before #642 (the fix for #629) keeps `cd Y` in its + own README, and `update-shape.py apply` never re-renders a README, so + change that one line by hand to `cd X`: the holder's directory is named + after its repository, `X`, never after the display name. The same README + draws its layout as `Y/Y` and calls the doubled `Y/Y` deliberate; read + `X/X` there, which is what `make siblings` checks the folder against. ## What changed since v1.0.1 v1.0.1 was cut from `release/v1.0` and added only the one-line install. Everything below landed on main after v1.0.0's commit, by area. The list -is current to main `40e678e`. **[placeholder: the pull requests that land -after main `40e678e` and before the release commit.]** +is current to main `64ec47c`. **[placeholder: the pull requests that land +after main `64ec47c` and before the release commit.]** ### adopt-project.py @@ -327,8 +334,8 @@ after main `40e678e` and before the release commit.]** resolves to, so the directory to rename may be a link's target, or the one a relative path was given from, and names a lone surrogate. The READMEs the scaffold and `family.py init` write quote a local clone URL - for a POSIX shell, so the `git clone` line under `a b/` pastes as one - command (#812, #693). + that needs it for a POSIX shell, so the `git clone` line under `a b/` + pastes as one command (#812, #693). - **Names that are not UTF-8.** In `bump-leg.py`, `scaffold-project.py` and `family.py`, a file name that is not UTF-8 in `git status` is `bump-leg-root-dirty` or the scaffold's DIRTY warning, and a @@ -378,6 +385,14 @@ after main `40e678e` and before the release commit.]** value opening with an indicator, an empty flow entry and a single pair in a flow sequence, so `k: ]` is not the text `]`, `pins: [,a]` is not `['a']`, and `k: [a: b]` is not the string `'a: b'` (#635, #392, #393). + A value refused for opening with an indicator is named by its line, and + the refusal says to write it in double quotes, spelled as the reader + reads it back, or to reword it. `plan --org @x` and + `scaffold-project.py --org @x` still name `--org`, now in the reader's + words and under its remedy, and `check` reports a `tracking_branch` + holding a NUL or a lone surrogate once, as `unsafe-value`, without the + `validate-manifest.py` finding above (#644), which names no plan key + (#817, #708). - **family.py.** `add` refuses, before the member is read and on every disk, any path under another spelling of `members/`, with a remedy per entry that never moves the members' checkouts (#574, #362, #363, #364). `add` @@ -405,6 +420,20 @@ after main `40e678e` and before the release commit.]** `member-gitmodules-absent`, `family.py remove` refuses a holder with no `.gitmodules` before it deinitialises anything, and two gitlink remedies name what they missed (#639, #611). +- **`validate-family.py`'s remedies, typed as printed.** After the `git rm` + that `member-gitlink-unnamed` prints, and before the commit, the finding + says the removal is staged and to commit it, and prints no `git rm`, + where it printed the same command again, which then failed. + `member-gitlink-absent`'s remedy puts the gitlink back by hand, in the + index at the commit the row pins, with its `.gitmodules` section where + HEAD's gives none, where it named a `family.py add` that refuses the row. + The `mv` for a directory under another spelling of the row's path moves + nothing where the disk takes the two names for one directory or the + directory holds a `.git`, so it no longer moves a member's own checkout. + In both validators, a shape-pin `files:` row whose `path:` is absent, + empty or not text is the finding `shape-pin-row`, where it was reported + as the path `None` (#747, #681). The first and the last were known + limits of v1.0.0. - **A family's name, printed.** `make siblings`, `park` and `resume` print the family's name and every member row, and no longer end in a traceback on a name such as `"a\ud83db"` or on a dash under @@ -440,14 +469,14 @@ after main `40e678e` and before the release commit.]** `.gitmodules`. A printed `git mv --sparse` says it needs git 2.38 or later, and the move back's `git rm --sparse` git 2.34, so no remedy fails as typed (#767, #685). -- **A holder's `make bootstrap` that left a member unchecked-out.** It no longer - says `family bootstrap ok` over a clone that left a member unchecked-out: - a subset clone, a member with `update = none`, a gitlink git stops at (a - stray `Members/

    ` beside `members/

    `, a member mounted by hand with no - row, a `.gitmodules` section with no url), a `.gitmodules` that git reads - otherwise than `git config` does (two sections for one path, a section - name git ignores, a value beginning with `-`), and a `.gitmodules` a merge - left unmerged now fail, and the line names the cause and the cure. This +- **A holder's `make bootstrap` that left a member unchecked-out.** It no + longer says `family bootstrap ok` there: a subset clone, a member with + `update = none`, a gitlink git stops at (a stray `Members/

    ` beside + `members/

    `, a member mounted by hand with no row, a `.gitmodules` + section with no url), a `.gitmodules` that git reads otherwise than + `git config` does (two sections for one path, a section name git + ignores, a value beginning with `-`), and a `.gitmodules` a merge left + unmerged now fail, and the line names the cause and the cure. This changes an exit code a script can see: ok became exit 1 (#682, #648). ### shape-doctor.py @@ -536,9 +565,10 @@ after main `40e678e` and before the release commit.]** a `$`, a backquote, a backslash or `!` (#726, #667). - **CI** on Linux only (#597), with a result for every landing on main (#593, #592), and a Git Bash job on Windows (#596, #585). Every test that - clones a just-pushed local remote now clones it in a way git's background - maintenance cannot race (#554, #544), and the test that holds the doctor's - NEXT lines to no `#` comment tail reads each string whole (#698, #679). + runs `git clone` on a just-pushed local remote now clones it in a way git's + background maintenance cannot race (#554, #544), and the test that holds + the doctor's NEXT lines to no `#` comment tail reads each string whole + (#698, #679). The test session turns git's automatic maintenance off, in command scope and in a global config file that includes the person's own, so a test's copy of a repository it has just committed in or pushed to no longer @@ -580,7 +610,13 @@ after main `40e678e` and before the release commit.]** v1.0.0's [known limits](v1.0.0.md#known-limits) that this release does not close still hold. **[placeholder: re-read every line there against the closed `v1.1.0` issues and the four tracking parents, #457 to #461, and list -here only what is still open at tag time.]** +here only what is still open at tag time.]** Still open among them, as +read so far: + +- The README's and the handbook's `--into` sentence for `family.py init` + gives its default, the directory you are standing in, but not that + `init` refuses that default inside a checkout of the standard + (`family-landing-in-standard`) (#695). New in v1.1.0: @@ -830,10 +866,10 @@ by moving a tag. works only once 1.1.0 is on PyPI. #590 (the package, `fe237ec`) and #599 (the release workflow, `32c22b0`) have landed. - **[placeholder: re-read at tag time.] Open `v1.1.0` milestone issues at - main `40e678e`, 2026-10-17T16:14Z (13 open, 122 closed), each with a pull + main `64ec47c`, 2026-10-17T16:42Z (11 open, 124 closed), each with a pull request open:** #563 (#630), #662 (#748), #663 (#723), #672 (#746), #674 - (#810), #681 (#747), #684 (#816), #686 (#795), #688 (#809), #689 (#813), - #692 (#731), #695 (#738), #708 (#817). + (#810), #684 (#816), #686 (#795), #688 (#809), #689 (#813), #692 (#731), + #695 (#738, planned for v1.1.1). Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone. From 1aeed03c1e503adf1f926e3401a869cf1021d4e5 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 18:03:31 +0000 Subject: [PATCH 56/58] docs/release-notes/v1.1.0.md: entries for #748, #731, #810, #809, #816, #813 and #723, v1.0.0's quoted-header and plain-path-clone limits closed and the counts current to main bad05d2 (#563) Each entry is the one RV630 corrected in its pre-review of the B2 stage, put at its anchor, and checked against its squash on main: the `git merge-tree --write-tree` of main before the squash with the head the entry was written from equals the squash's tree for all seven. - #810 (#674, `d17564d9`): a new bullet, "`apply`'s lines, its branch and its headers.", after "Headers, branches and messages.". It closes v1.0.0's quoted `files:`/`shape:` header (v1.0.0.md:439) and `apply`'s half of the paths listed as staged (:433); `bump-leg.py`'s half waits for #746. - #731 (#692, `d66a9054`): appended to "Folders, asked before they are made.". - #813 (#689, `f6d17bc5`): appended to "`--tracking-branch`.". - #809 (#688, `42aed788`): a new bullet, "What `family.py init` records and commits as.", after "Names that are not UTF-8.". - #816 (part of #684, `fa04d2cd`): a new bullet, "More holder states, named.", after "family.py refuses before it writes.". #816 closes no issue; #684 keeps its reftable case and is on v1.1.1. - #748 (#662, `431c8cf8`), then #723 (#663, `bad05d21`): in the CI bullet, after #713's sentence. With #554 and #578, #748 closes v1.0.0's limit on fixtures that clone a just-pushed remote by plain path (:530). The counts, in the header comment, the counts paragraph, "What changed" and the open list: main `bad05d2`; 120 pull requests since `bfed143`; at 18:02Z, 3 of the `v1.1.0` milestone's 133 issues open and 130 closed. The open list is #563 (#630), #672 (#746) and #686 (#795); #684 and #695 (#738) are on v1.1.1. #746 and #795 have not landed, so neither has an entry yet. Nothing since 20004887 touches pyproject.toml, src/openreposhape/, install.sh or .github/workflows/. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/v1.1.0.md | 75 +++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index 203d3de6..113d99bf 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -1,7 +1,7 @@ # openRepoShape v1.1.0 @@ -11,9 +11,9 @@ PowerShell cannot run the one line (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At main `64ec47c`, on 2026-10-17, -113 pull requests had landed on main since v1.0.0's commit, `bfed143`; at -16:42Z, 124 of the `v1.1.0` milestone's 135 issues were closed. +**[placeholder: the counts at tag time.]** At main `bad05d2`, on 2026-10-17, +120 pull requests had landed on main since v1.0.0's commit, `bfed143`; at +18:02Z, 130 of the `v1.1.0` milestone's 133 issues were closed. ## What 1.1.0 adds @@ -117,8 +117,8 @@ CI on Linux only (#597). v1.0.1 was cut from `release/v1.0` and added only the one-line install. Everything below landed on main after v1.0.0's commit, by area. The list -is current to main `64ec47c`. **[placeholder: the pull requests that land -after main `64ec47c` and before the release commit.]** +is current to main `bad05d2`. **[placeholder: the pull requests that land +after main `bad05d2` and before the release commit.]** ### adopt-project.py @@ -258,6 +258,17 @@ after main `64ec47c` and before the release commit.]** trailing comment is rewritten and keeps the comment, `--branch` is judged the same from any directory, and `apply`'s messages say what is true (#558, #437, #431, #535). +- **`apply`'s lines, its branch and its headers.** A rollback line for a + file that is gone, under a directory this user cannot write in, says to + make that directory writable first. `committed on :` names what + the commit holds, where it named `project.yaml` beside a pin that moved + alone. A branch named like a tag is read, and pushed in the run and in + both printed push lines, by its full name, where the push failed + `matches more than one`. A `files:` or `shape:` header spelled + `"files":` or `files :` is found as the reader finds it, where `apply` + refused the pin that `check` had sent it to (#810, #674). Of v1.0.0's + known limits, this closes the quoted header, and `apply`'s half of the + paths listed as staged. - **`bump-leg.py --dry-run` gives the real run's answer.** `--to` an annotated tag's own id is peeled to its commit in both runs, and a pin layout the rewrite refuses is refused by the dry run too (#561, #534, @@ -319,9 +330,21 @@ after main `64ec47c` and before the release commit.]** where a folder goes, or a `gh repo view` that fails another way, by name, as the real run does, and a refused `init` leaves no empty family folder (#622, #609). + Those refusals, and `scaffold-target-exists`, `family-target-exists` and + `family-operation-in-progress`, spell a path holding a character that is + not printable as Python's `repr` does, so an ESC or a line break in a + folder's name no longer reaches the terminal (#731, #692). - **`--tracking-branch`.** The tracking branch is pushed by its full name, so `--tracking-branch +x` scaffolds a project and initialises a family instead of failing at the push after every remote exists (#643, #421). + `family.py add`, `bump` and `remove` print their push line with the + branch named in full on both sides, so a branch beginning `+` or named + like a tag pushes as itself, where the line force-pushed another branch + over origin's, or failed. A branch name with a part longer than a disk + holds (a last part over 250 bytes, which git locks as `.lock`) is + refused before anything exists by `scaffold-project.py`, `family.py init` + and `adopt-project.py`, `--dry-run` alike, where the first commit failed + with every remote made (#813, #689). - **`--local-remote-dir`.** A `--local-remote-dir` whose clone URL is not text a README can hold, such as a directory named in a legacy code page, is refused before any remote or folder exists, `--dry-run` alike, where @@ -342,6 +365,16 @@ after main `64ec47c` and before the release commit.]** `git config user.name` that is not UTF-8 is refused `family-unwritable-value`, naming `--created-by`, where each ended in a `UnicodeDecodeError` traceback (#641, #423, #426). +- **What `family.py init` records and commits as.** It asks whether + `family.yaml` can hold the creator whichever of `--created-by`, + `GIT_AUTHOR_NAME` or `git config user.name` gave it, and the remedy names + that source; it asks git, before the remote exists, whether the holder's + first commit can be made with the `GIT_AUTHOR_*` and `GIT_COMMITTER_*` + values set (`family-bad-commit-identity`); and it refuses a `--family` + that is a Windows device name, such as `CON` (`family-bad-holder-name`). + `--dry-run` refuses alike, where a creator holding a line break or a + control character was taken and a name git refuses failed after the + remote and the work directory existed (#809, #688). - **A re-run's legs.** A leg that `gh` sees and git cannot read is refused `scaffold-remote-exists`, with git's terminal prompt off, and the reuse re-run's visibility lines go by each leg as it stands (#565, #542). @@ -411,6 +444,16 @@ after main `64ec47c` and before the release commit.]** the sparse checkout leaves out, and a file named `members`. Each failed after writing and was rolled back, or was committed in the wrong place (#626, #612). +- **More holder states, named.** `add`, `bump` and `remove` name, before + their first write and `--dry-run` alike, a `git bisect` in progress, a + rebase directory that holds no rebase, non-cone sparse rules that hold + no pattern, a merge conflicted in `family.yaml` itself, and a `members` + file that HEAD holds and the work tree no longer does, each with the way + out that works there. They refuse a `family.yaml` holding a change HEAD + does not, staged or not (`family-manifest-uncommitted`), which their + commit took in beside their own row, so a `bump` an earlier run staged + and did not commit is told to put back HEAD's copy and run again (#816, + #684). - **The `switch -c` remedy.** Where an earlier act left the branch, `family.py` and `validate-family.py` say to give the new branch another name, or to go back to the old one only if it holds work not merged yet, @@ -573,6 +616,18 @@ after main `64ec47c` and before the release commit.]** and in a global config file that includes the person's own, so a test's copy of a repository it has just committed in or pushed to no longer meets a repack deleting its loose objects (#713, #645). + The five `git submodule update --init` runs that still cloned a test's + bare remotes by plain path, four of them the runs v1.0.0 listed among its + test-fixture limits, now read them through git's transport too, by the + `url..insteadOf` rewrite that `clone_just_pushed` uses, now + built by one helper, and a pasted line's environment keeps the session's + own git settings (#748, #662). With #554 and #578, that closes v1.0.0's + limit on fixtures that clone a just-pushed remote by plain path. + The tests that need a file name or a branch that is not UTF-8 ask one + pair of helpers whether this system can carry one, and skip on Windows + and on a disk that refuses such a name, as macOS's does; the helpers are + held to Windows' answer on Linux, with its `os.fsencode` emulated (#723, + #663). Tests now fail if `validate-pins.py`'s workflow scan reads a directory, a dangling symlink or a looping symlink named `.yml`, or if `siblings.py`'s headers print the holder's root and folder raw (#706, #664). @@ -866,10 +921,10 @@ by moving a tag. works only once 1.1.0 is on PyPI. #590 (the package, `fe237ec`) and #599 (the release workflow, `32c22b0`) have landed. - **[placeholder: re-read at tag time.] Open `v1.1.0` milestone issues at - main `64ec47c`, 2026-10-17T16:42Z (11 open, 124 closed), each with a pull - request open:** #563 (#630), #662 (#748), #663 (#723), #672 (#746), #674 - (#810), #684 (#816), #686 (#795), #688 (#809), #689 (#813), #692 (#731), - #695 (#738, planned for v1.1.1). + main `bad05d2`, 2026-10-17T18:02Z (3 open, 130 closed), each with a pull + request open:** #563 (#630), #672 (#746), #686 (#795). #684, which #816 + fixed but for its reftable case, and #695 (#738) are on the `v1.1.1` + milestone now. Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone. From d697b366e2e2a25a62c5db60acbee992581eb351 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 19:09:46 +0000 Subject: [PATCH 57/58] docs/release-notes/v1.1.0.md: #746's entry, v1.0.0's staged-paths limit closed as one clause, the list's and the known limits' markers made final, the triage lines named by their issues, and the counts current to main fc86720 (#563) - #746 (#672, `fc867207`): a new bullet, "`bump-leg.py`'s branch and its lines.", after "`bump-leg.py --dry-run` gives the real run's answer.". `git merge-tree --write-tree bad05d21 a5139a32`, the head #746 was gated and landed on, equals the squash's tree `bb920ac1`. The head the entry was written from, `4a014a79`, sat on #723's head before its squash and does not merge clean with main; `scripts/bump-leg.py` is one blob (`226e7b0d`) at `4a014a79`, `a5d473ee`, `a5139a32` and the squash, and the squash's own lines in each of its five files equal that head's, so the entry stands as written. - v1.0.0's limit on the paths listed as staged (v1.0.0.md:433) is one clause, in #746's entry: with #810 it closes. #810's half-clause is cut. `bump-leg.py --dry-run` over a pin its validators refuse (:440) stays a limit, which the dry run now says. - "What changed" runs to main `fc86720`, and says #795 is added if it lands next; Known limits' re-read marker and "as read so far" are cut. The counts marker stays as the release-body tool reads it. - Known limits name each triage by its issue: "the checklist triage of the L4 lines on #457 and #459" and "of the L1 lines on #457", where they named lanes 4 and 1 and D9, and each id reads "#457's L4-05", "#459's L4-24" and so on. - Counts in the four places: main `fc86720`; 121 pull requests since `bfed143`; at 19:08Z, 131 of the `v1.1.0` milestone's 133 issues closed, the open two #563 (#630) and #686 (#795). Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/v1.1.0.md | 123 ++++++++++++++++++----------------- 1 file changed, 65 insertions(+), 58 deletions(-) diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index 113d99bf..c3678dd9 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -1,7 +1,7 @@ # openRepoShape v1.1.0 @@ -11,9 +11,9 @@ PowerShell cannot run the one line (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At main `bad05d2`, on 2026-10-17, -120 pull requests had landed on main since v1.0.0's commit, `bfed143`; at -18:02Z, 130 of the `v1.1.0` milestone's 133 issues were closed. +**[placeholder: the counts at tag time.]** At main `fc86720`, on 2026-10-17, +121 pull requests had landed on main since v1.0.0's commit, `bfed143`; at +19:08Z, 131 of the `v1.1.0` milestone's 133 issues were closed. ## What 1.1.0 adds @@ -117,8 +117,7 @@ CI on Linux only (#597). v1.0.1 was cut from `release/v1.0` and added only the one-line install. Everything below landed on main after v1.0.0's commit, by area. The list -is current to main `bad05d2`. **[placeholder: the pull requests that land -after main `bad05d2` and before the release commit.]** +runs to main `fc86720`; #795, if it lands next, is added before the tag. ### adopt-project.py @@ -267,12 +266,21 @@ after main `bad05d2` and before the release commit.]** `matches more than one`. A `files:` or `shape:` header spelled `"files":` or `files :` is found as the reader finds it, where `apply` refused the pin that `check` had sent it to (#810, #674). Of v1.0.0's - known limits, this closes the quoted header, and `apply`'s half of the - paths listed as staged. + known limits, this closes the quoted header. - **`bump-leg.py --dry-run` gives the real run's answer.** `--to` an annotated tag's own id is peeled to its commit in both runs, and a pin layout the rewrite refuses is refused by the dry run too (#561, #534, #438). +- **`bump-leg.py`'s branch and its lines.** `bump-leg.py` reads the branch + in full, so a tag of the same name no longer hides the tracking branch; + its push line names the branch on both sides, so a branch beginning `+` + is no longer pushed as a forced update; it spells each file name it + lists; its `committed` line lists what the commit holds, and a gitlink + moved by hand is no longer reported `X -> X`; and a dry run says it runs + no validator, where its behaviour is unchanged (#746, #672). With #810, + that closes v1.0.0's known limit on the paths `bump-leg.py` and `apply` + listed as staged, and on `X -> X`. A dry run that exits 0 over a pin its + own validators refuse stays one, which the dry run now says. - **A `git switch -c` line that is no dead end.** `bump-leg.py` and `setup-project.py` say under their `git switch -c` line what to do where an earlier act left that branch, so `already exists` is no dead end, as @@ -663,10 +671,7 @@ after main `bad05d2` and before the release commit.]** ## Known limits v1.0.0's [known limits](v1.0.0.md#known-limits) that this release does not -close still hold. **[placeholder: re-read every line there against the -closed `v1.1.0` issues and the four tracking parents, #457 to #461, and list -here only what is still open at tag time.]** Still open among them, as -read so far: +close still hold. Still open among them: - The README's and the handbook's `--into` sentence for `family.py init` gives its default, the directory you are standing in, but not that @@ -696,114 +701,116 @@ New in v1.1.0: non-administrator make a symlink only in Developer Mode), or put `~/.local/bin` on `PATH` (#596, #585). -Kept as known limits by lane openRepoShape-4's checklist triage, which Brett -Heap approved at 2026-10-16T13:36:07Z; each line's text stays on #457 or -#459 ([#630](https://github.com/opensoft/openRepoShape/pull/630#issuecomment-6217406337)): +Kept as known limits by the checklist triage of the L4 lines on #457 and +#459, which Brett Heap approved at 2026-10-16T13:36:07Z; each line's text +stays on its issue ([#630](https://github.com/opensoft/openRepoShape/pull/630#issuecomment-6217406337)): - Windows: adopting a source whose path holds a character NTFS refuses, such as a TAB or a control character, is unverified there, because such a path - cannot be checked out (L4-05). + cannot be checked out (#457's L4-05). - Windows: adopting a source whose path is not UTF-8, such as `b'caf\xe9'`, - is unverified there, because such a path cannot be made (L4-06). + is unverified there, because such a path cannot be made (#457's L4-06). - Windows: the submodule plan of a source whose tree names hold a newline, a TAB or a backslash is unverified there, because such a name is not kept in - a tree (L4-07). + a tree (#457's L4-07). - Windows: adopting a source that tracks symlinks is unverified there, because they check out as plain files unless `core.symlinks=true` - (Developer Mode or an elevated shell) (L4-08). + (Developer Mode or an elevated shell) (#457's L4-08). - A root adopted before #471's fix, from a source that kept its own root `.gitattributes`, has no line-ending block, so on Windows its pinned copies still check out CRLF and `make bootstrap` reports - `shape-copy-drift` (L4-17). + `shape-copy-drift` (#457's L4-17). - `adopt-project.py` compares an `origin` with the source by Python's `casefold`, which is broader than GitHub's ASCII-only folding, so a path `origin` `.../straße/Atlas.git` and `--org strasse` are taken for one - repository (L4-43). + repository (#457's L4-43). - `check` says `plan ok` for a source whose own repository is literally `None/`, `False/` or `0/`, though `execute` still - refuses it (L4-45). + refuses it (#457's L4-45). - A test, `tests/test_adopt_ignored_shape_files.py`, feeds `git hash-object --stdin` a link target as text, not bytes; harmless - today, because no target holds a newline or a non-ASCII byte (L4-54). + today, because no target holds a newline or a non-ASCII byte (#457's L4-54). - `setup-project.py` strips only C0 control characters and DEL from a value it prints, so C1 controls and direction-override characters still reach - the terminal (L4-24). + the terminal (#459's L4-24). - The YAML reader is a subset by design, and still reads, where PyYAML refuses or reads another thing, `k: - a`, `k: [- ]`, `k: [e?]`, `k: [|a]`, - a block key that opens with an indicator, and a flow `?a` (L4-156). + a block key that opens with an indicator, and a flow `?a` (#459's L4-156). -Kept as known limits by lane openRepoShape-1's checklist triage (D9), which -Brett Heap approved at 2026-10-16T11:21:17Z; each line's text stays on #457 -([#457](https://github.com/opensoft/openRepoShape/issues/457#issuecomment-6214768828)): +Kept as known limits by the checklist triage of the L1 lines on #457, +which Brett Heap approved at 2026-10-16T11:21:17Z; each line's text stays +on #457 ([#457](https://github.com/opensoft/openRepoShape/issues/457#issuecomment-6214768828)): - The `--pin-source` digest reader runs git with lazy fetch on and the terminal prompt allowed, so a partial clone can fetch and a private https - remote can prompt, where the help says it uses no network (L1-02). + remote can prompt, where the help says it uses no network (#457's L1-02). - `update-shape.py apply --add` and the doctor read no git attributes, so a nested `.gitattributes` added after adoption makes CRLF clones drift with - no finding that names the cause (L1-08). + no finding that names the cause (#457's L1-08). - A setting passed only through a parent git's `-c`, such as `core.attributesFile`, reaches the split's `git add` but not `check`'s re-encoding question, so `check` passes and `execute` refuses `adopt-lost` - after both legs exist (L1-10). + after both legs exist (#457's L1-10). - A `transfer.hideRefs` or `receive.hideRefs` pattern that covers the split branch passes `check`; `execute` makes both legs, and the split push is - then denied (L1-13). + then denied (#457's L1-13). - A tracking branch the source hides from `ls-remote` passes `check`, and `execute` refuses it `plan-tracking-branch-missing` before any leg - (L1-14). + (#457's L1-14). - With `org:` and `adopt_branch:` both outside the alphabet, `check` - refuses `org` first and `execute` refuses `adopt_branch` first (L1-17). + refuses `org` first and `execute` refuses `adopt_branch` first + (#457's L1-17). - Two branch names that differ "only by case" are judged with Python's `casefold`, which also folds `ß` to `ss` and `ſ` to `s`; how APFS, NTFS - and GitHub treat such pairs is not established (L1-18). + and GitHub treat such pairs is not established (#457's L1-18). - Under `core.symlinks=false`, a link at the `shape/` copy of a path the root holds through a link above it gets a finding whose every fix leads on - to `adopt-symlinks-off` (L1-29). + to `adopt-symlinks-off` (#457's L1-29). - Under `core.symlinks=false`, `.github -> ci` with `ci -> ""` reports only `plan-empty-symlink` for `ci` and never names `.github`, so the fix takes - two rounds (L1-30). + two rounds (#457's L1-30). - Under `core.symlinks=false`, `check` passes a link above a directory the root keeps (`scripts -> ci`), and `execute` then refuses - `adopt-symlinks-off` (L1-31). + `adopt-symlinks-off` (#457's L1-31). - A failure of `check`'s probe `git init` other than an `OSError` stops it - with "a git or gh command failed" instead of a finding (L1-32). + with "a git or gh command failed" instead of a finding (#457's L1-32). - A url whose organisation is refused is a dead end: `https://example.invalid/café/Atlas.git` with `--org acme` is refused as not the source, and the suggested `--org café` is refused `unsafe-value` - (L1-40). + (#457's L1-40). - An `origin` that `url..insteadOf` rewrites to a local path reads as - a path, so nothing is compared (L1-41). + a path, so nothing is compared (#457's L1-41). - A url's host is never checked, so a GitLab url is compared as GitHub's - (L1-42). + (#457's L1-42). - A drive-relative `origin` such as `C:foo/bar` reads as git's `host:path` - form, though git on Windows reads it as a path (L1-44). + form, though git on Windows reads it as a path (#457's L1-44). - Only `plan` prints a NOTE for an `origin` that is a path; `check` and - `execute` print none (L1-45). + `execute` print none (#457's L1-45). - `https://github.com/acme//Atlas`, with a doubled slash, names no - repository, so nothing is compared and `plan` exits 0 (L1-46). -- A path `origin` that is not UTF-8 gets no NOTE (L1-47). + repository, so nothing is compared and `plan` exits 0 (#457's L1-46). +- A path `origin` that is not UTF-8 gets no NOTE (#457's L1-47). - `plan` does not follow a path `origin` to that repository's own `origin`, - which would catch more mistyped `--org` values (L1-48). + which would catch more mistyped `--org` values (#457's L1-48). - A submodule url naming an empty bare repository answers `ls-remote`, so `check` passes and `execute` verifies, and the recursive clone then fails - with `upload-pack: not our ref` (L1-50). + with `upload-pack: not our ref` (#457's L1-50). - An absolute local-path submodule url that answers on this machine, kept in a leg pushed to GitHub, works only on machines that have that path, and - nothing notes it (L1-51). + nothing notes it (#457's L1-51). - A path on a mounted network filesystem (NFS, a mapped drive) looks like - this disk to git and is asked; its spelling cannot tell it apart (L1-52). + this disk to git and is asked; its spelling cannot tell it apart + (#457's L1-52). - The NOTE for a submodule url that is not asked gives its network reason for a bare relative url such as `dependency`, which names no network - (L1-53). + (#457's L1-53). - On Windows, a UNC `--local-remote-dir` (`\\server\share\remotes`) makes every relative submodule url resolve to a UNC path, which is never asked - and gets no NOTE (L1-54). + and gets no NOTE (#457's L1-54). - A test, `tests/test_adopt_pins.py`, writes its stub `gh` with `Path.write_text`, so a CRLF emulation run fails one case; not a product - bug (L1-60). + bug (#457's L1-60). - #618's guard test does not clear `GIT_ALLOW_PROTOCOL`, so a caller's - environment could mask a missing guard; not a product bug (L1-62). + environment could mask a missing guard; not a product bug (#457's L1-62). ## How this release is cut (remove at tag time) @@ -921,10 +928,10 @@ by moving a tag. works only once 1.1.0 is on PyPI. #590 (the package, `fe237ec`) and #599 (the release workflow, `32c22b0`) have landed. - **[placeholder: re-read at tag time.] Open `v1.1.0` milestone issues at - main `bad05d2`, 2026-10-17T18:02Z (3 open, 130 closed), each with a pull - request open:** #563 (#630), #672 (#746), #686 (#795). #684, which #816 - fixed but for its reftable case, and #695 (#738) are on the `v1.1.1` - milestone now. + main `fc86720`, 2026-10-17T19:08Z (2 open, 131 closed), each with a pull + request open:** #563 (#630), #686 (#795). #684, which #816 fixed but + for its reftable case, and #695 (#738) are on the `v1.1.1` milestone + now. Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone. From aef568f02a9c626c9ae93f67e1a508c14684312d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 20:16:45 +0000 Subject: [PATCH 58/58] docs/release-notes/v1.1.0.md: #795's entry, the list complete to main 6f64eea, RV630's lead and two named limits under Known limits, and the counts current to main 6f64eea (#563) - #795 (#686, `6f64eea2`): a new bullet, "`bump --to`, reachable or not.", before "family.py refuses before it writes.", the pending file's text as written. `git merge-tree --write-tree fc867207 ` equals the squash's tree `0f2d2ddd` for `17bbbaba` and for the final head `cf143c69`. For the head the text was written from, `cf5e64b3`, the merged tree differs from the squash only in `tests/test_family_bump_reachability_words.py`, so `scripts/family.py` is as the text says. - "What changed" runs to main `6f64eea`, with no word of a later pull request. - Known limits, RV630's words: the lead says the list is not all of v1.0.0's open limits, and names two this release worked on and left open, the `--into` sentence (#695) and `bump-leg.py --dry-run` over a pin its validators refuse (#746, #672). - Counts in the four places: main `6f64eea`; 122 pull requests since `bfed143`; at 20:15Z, 132 of the `v1.1.0` milestone's 133 issues closed, the one open #563 (#630). Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/v1.1.0.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md index c3678dd9..d39b35bc 100644 --- a/docs/release-notes/v1.1.0.md +++ b/docs/release-notes/v1.1.0.md @@ -1,7 +1,7 @@ # openRepoShape v1.1.0 @@ -11,9 +11,9 @@ PowerShell cannot run the one line (#563). This release also carries v1.0.1's one-line install into main (#564), fixes limits that v1.0.0 listed, and runs CI on Linux only (#597). -**[placeholder: the counts at tag time.]** At main `fc86720`, on 2026-10-17, -121 pull requests had landed on main since v1.0.0's commit, `bfed143`; at -19:08Z, 131 of the `v1.1.0` milestone's 133 issues were closed. +**[placeholder: the counts at tag time.]** At main `6f64eea`, on 2026-10-17, +122 pull requests had landed on main since v1.0.0's commit, `bfed143`; at +20:15Z, 132 of the `v1.1.0` milestone's 133 issues were closed. ## What 1.1.0 adds @@ -117,7 +117,7 @@ CI on Linux only (#597). v1.0.1 was cut from `release/v1.0` and added only the one-line install. Everything below landed on main after v1.0.0's commit, by area. The list -runs to main `fc86720`; #795, if it lands next, is added before the tag. +runs to main `6f64eea`. ### adopt-project.py @@ -446,6 +446,14 @@ runs to main `fc86720`; #795, if it lands next, is added before the tag. `make siblings` asks about the family folder before it clones, clones each member on its tracking branch, and `family.py siblings` runs the holder's own copy (#578, #543). +- **`bump --to`, reachable or not.** `family.py bump` fetches every branch + of the member's `origin`, whatever fetch refspec the mount has, and + prunes the remote-tracking branches `origin` has deleted, so a commit a + branch holds is no longer refused for a refspec narrowed by hand, and one + only a deleted branch held is no longer pinned. A commit only a tag of the + remote reaches is told that a tag is not a branch, and a mount with no + remote named `origin` is told so, where each was told the pin cannot be + fetched, or to fix the network (#795, #686). - **family.py refuses before it writes.** `add`, `bump` and `remove` refuse, before their first write, a merge, cherry-pick, revert, rebase or `git am` in progress, a `.gitmodules`, `members/` or `family.yaml` that @@ -671,12 +679,17 @@ runs to main `fc86720`; #795, if it lands next, is added before the tag. ## Known limits v1.0.0's [known limits](v1.0.0.md#known-limits) that this release does not -close still hold. Still open among them: +close still hold; this list is not all of them. Two that work on this +release named and left open: - The README's and the handbook's `--into` sentence for `family.py init` gives its default, the directory you are standing in, but not that `init` refuses that default inside a checkout of the standard (`family-landing-in-standard`) (#695). +- `bump-leg.py --dry-run` over a pin its own validators already refuse, + such as one with a stray tab line, exits 0 and prints the `old -> new` + line, where the real run exits 2 `bump-leg-validators-red` and rolls + back. The dry run now says that it runs no validator (#746, #672). New in v1.1.0: @@ -928,10 +941,9 @@ by moving a tag. works only once 1.1.0 is on PyPI. #590 (the package, `fe237ec`) and #599 (the release workflow, `32c22b0`) have landed. - **[placeholder: re-read at tag time.] Open `v1.1.0` milestone issues at - main `fc86720`, 2026-10-17T19:08Z (2 open, 131 closed), each with a pull - request open:** #563 (#630), #686 (#795). #684, which #816 fixed but - for its reftable case, and #695 (#738) are on the `v1.1.1` milestone - now. + main `6f64eea`, 2026-10-17T20:15Z (1 open, 132 closed), each with a pull + request open:** #563 (#630). #684, which #816 fixed but for its + reftable case, and #695 (#738) are on the `v1.1.1` milestone now. Each that lands before the tag gets its line above, and each that does not is a known limit or moves to the next milestone.