diff --git a/README.md b/README.md index d1641310..2664a842 100644 --- a/README.md +++ b/README.md @@ -234,11 +234,11 @@ curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/in `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. +tag, not at main. It installs `git-filter-repo` with `pipx`, or `uv`, where it +can, or prints the command, runs `openRepoShape --preflight`, and says last if +the machine is ready to adopt or only to scaffold. `install.sh` never runs +`sudo`; the preflight 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: diff --git a/docs/handbook.html b/docs/handbook.html index 59ec34d6..801cc4ac 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -498,7 +498,8 @@

The fastest path — macOS, Linux, WSL2

openRepoShape <Project> --org <your-org>

That first line installs ONE command into ~/.local/bin: the newest release, pinned to its tag, with git-filter-repo where pipx - can install it, and it ends with openRepoShape --preflight. The + or uv can install it, and it ends with openRepoShape --preflight + and whether the machine is ready to adopt or only to scaffold. The estate verbs park and resume, for carrying in-flight work to another workstation, are opensoft/openRepoTools’ and have a one-liner of @@ -609,7 +610,7 @@

The one thing to read before electing it

- +

Setup

Start here

@@ -780,11 +781,12 @@

Fetch the flow, in the directory the new project should 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.

+ git-filter-repo with pipx, or uv, where it can, or + prints the command, runs openRepoShape --preflight, and says last if the + machine is ready to adopt or only to scaffold. install.sh itself never runs + sudo; the preflight 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
@@ -969,8 +971,9 @@

Consent rules, on every platform

  • Every action the tool offers that changes the machine or the account is a typed yes, at the same prompt: Type yes to continue:. install.sh asks nothing before its own two acts, because running it is your act: it places the - command and installs git-filter-repo with pipx where it can, and itself - never runs sudo. The preflight it ends with asks before each offer, as above.
  • + command and installs git-filter-repo with pipx or uv + where one is there, and itself never runs sudo or installs either of them. The + preflight it ends with asks before each offer, as above.
  • --yes skips the scaffold question only. It does not answer an install offer, gh auth login or gh auth setup-git.
  • With no terminal attached there is no offer at all.
  • @@ -1042,7 +1045,7 @@

    What you have afterwards — the same on every platform

    - +

    Setup

    If it fails part-way

    @@ -1077,7 +1080,7 @@

    If it fails part-way

    - + @@ -1135,7 +1138,7 @@

    Reading private legs in CI

    - + @@ -1371,7 +1374,7 @@

    The InkRouter example

    - + @@ -1668,7 +1671,7 @@

    Is this repository compliant? openRepoShape --doctor

    - + diff --git a/install.sh b/install.sh index 453507bb..7f91cd5d 100755 --- a/install.sh +++ b/install.sh @@ -23,11 +23,14 @@ # 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. +# is not, or with `uv` when uv is there and pipx is not (#595); +# otherwise the one command that installs it, printed. +# 4. THE INSTALLED COMMAND'S `--preflight`, LAST, and its verdict, then the +# two readinesses: `adoption:` and `done:` (#595). # # 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 +# command and, through the pipx or uv already there, git-filter-repo. It +# never installs pipx, uv or a system package. 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. @@ -41,6 +44,9 @@ # 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. +# What it changes is the summary (#595): `adoption:` says whether it is there +# and, where it is not, the command to run next, and `done:` says the machine +# is ready only when it is, and "ready to scaffold, not yet to adopt" when not. # # 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 @@ -60,6 +66,10 @@ REPO="${OPENREPOSHAPE_REPO:-opensoft/openRepoShape}" #: installs is what the adoption suites ran against. FILTER_REPO_PIN="git-filter-repo==2.47.0" +#: Where uv says how to install uv itself, named for a machine with neither +#: pipx nor uv and no package manager `filter_repo_command` knows (#595). +UV_INSTALL_PAGE="https://docs.astral.sh/uv/getting-started/installation/" + WORKDIR="" #: What `latest_from_redirect` and `latest_from_gh` found, and when they did @@ -68,6 +78,12 @@ WORKDIR="" FOUND="" FAULT="" +#: What step 3 found, for the summary `main` ends with (#595): 1 when +#: `adopt-project.py` will find git-filter-repo, and what the `adoption:` +#: line says, which names the command to run next where it will not. +ADOPTION_READY=0 +ADOPTION="" + say() { printf '%s\n' "$*"; } die() { printf '\nREFUSED: %s\n' "$1" >&2; exit "${2:-2}"; } @@ -81,15 +97,18 @@ install.sh: install openRepoShape, pinned to a release. With no it installs the newest release. It fetches that release's `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. +installs git-filter-repo with pipx, or with uv where there is no pipx, when +git-filter-repo is not there (or prints the command that installs it), and +ends with `openRepoShape --preflight` and two lines: `adoption:`, whether +git-filter-repo is there, and `done:`. install.sh itself never runs sudo and +never installs pipx or uv; 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 tag. 2: -refused, and nothing was installed. +refused, and nothing was installed. git-filter-repo, which only adopting +needs, never changes it. $OPENREPOSHAPE_REPO owner/name to install from (default opensoft/openRepoShape) $OPENREPOSHAPE_BIN_DIR where the command goes (default ~/.local/bin) @@ -216,60 +235,106 @@ looks_like_the_launcher() { return 1 } -# 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. +# THE ONE COMMAND THAT INSTALLS IT HERE, for a machine with neither pipx nor +# uv, PRINTED AND NEVER RUN, in POSIX shell, and nothing but the command, so +# it pastes and runs as printed in any shell (#595, the review on #598: an +# interactive zsh hands a `# comment` to the command as arguments). Never +# `pip install --user`: Debian 12, Ubuntu 23.04 and Arch, among others, mark +# the system Python externally managed (PEP 668) and refuse it. +# +# * brew's, where `brew` is; +# * pipx from apt or dnf, which takes `sudo` and is the person's to type, +# then git-filter-repo through it at the version CI runs, then `pipx +# ensurepath`, which exits 0 whether or not it had a directory to add; +# * Arch's own package where `pacman` is, which lands on PATH in one step. +# pacman cannot pin a version: Arch's `extra` holds 2.47.0, CI's, as of +# 2026-10-15; +# * and with no package manager this knows, uv's, once uv itself is +# installed, which the caller says first: the one command here that +# needs a tool that is not on this machine. filter_repo_command() { - local candidate if command -v brew >/dev/null 2>&1; then printf '%s' "brew install git-filter-repo" - return 0 + elif command -v apt-get >/dev/null 2>&1; then + printf '%s' "sudo apt-get install -y pipx && pipx install $FILTER_REPO_PIN && pipx ensurepath" + elif command -v dnf >/dev/null 2>&1; then + printf '%s' "sudo dnf install -y pipx && pipx install $FILTER_REPO_PIN && pipx ensurepath" + elif command -v pacman >/dev/null 2>&1; then + printf '%s' "sudo pacman -S --needed git-filter-repo" + else + printf '%s' "uv tool install $FILTER_REPO_PIN" 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 +} + +adoption_ready() { + ADOPTION_READY=1 + ADOPTION="git-filter-repo is installed, which adopt-project.py needs beyond what the preflight checks." +} + +# `$1` says why it is not ready, and `$2` what to do first, in the caller's +# own words: a command comes after `run: `, at the end of the line, or between +# backticks, so it pastes as printed. +adoption_not_ready() { + ADOPTION_READY=0 + ADOPTION="NOT READY: adopt-project.py needs git-filter-repo, and $1. Before adopting a repository, $2" +} + +# ONE INSTALL, WITH THE pipx OR uv ALREADY HERE, into an environment of its +# own and at the version CI runs. Installed is not yet found: either tool puts +# the command in a directory of its own (`~/.local/bin` by default) that this +# shell may not have on PATH, and `adopt-project.py` looks on PATH, so the +# install is checked by looking where it will. `$1` names the tool, `$2` is +# what puts its directory on PATH, and the rest is the install. +filter_repo_install() { + local tool="$1" ensure="$2" status=0 + shift 2 + say "git-filter-repo: not installed; installing it with $tool, because adopt-project.py needs it: $*" + "$@" || status=$? + if [ "$status" -ne 0 ]; then + say "git-filter-repo: $tool could not install it (exit $status). Before adopting a repository, install it: $*" + adoption_not_ready "$tool could not install it (exit $status)" "run: $*" + elif command -v git-filter-repo >/dev/null 2>&1; then + say "git-filter-repo: installed with $tool" + adoption_ready + else + say "git-filter-repo: installed with $tool, into a directory that is not on PATH yet: run \`$ensure\` and open a new shell" + adoption_not_ready "$tool installed it into a directory that is not on PATH yet" "run \`$ensure\`, then open a new shell" 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 + local found how="run:" if found="$(command -v git-filter-repo 2>/dev/null)" && [ -n "$found" ]; then say "git-filter-repo: already installed ($found)" + adoption_ready 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)" + adoption_ready 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 + filter_repo_install pipx "pipx ensurepath" pipx install "$FILTER_REPO_PIN" + return 0 + fi + if command -v uv >/dev/null 2>&1; then + filter_repo_install uv "uv tool update-shell" uv tool install "$FILTER_REPO_PIN" 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)" + found="$(filter_repo_command)" + case "$found" in + "uv "*) how="install uv ($UV_INSTALL_PAGE), then run:" ;; + esac + say "git-filter-repo: not installed, and there is neither pipx nor uv to install it with. Only adopt-project.py needs it; before adopting a repository, $how" + say " $found" + adoption_not_ready "it is not installed" "$how $found" } main() { local version="" asked launcher_url bin_dir target shown status=0 pinned=1 + local machine="this machine is ready" # 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 @@ -379,6 +444,16 @@ main() { OPENREPOSHAPE_REF="$version" "${BASH:-bash}" "$target" --preflight || status=$? fi + # THE TWO READINESSES, LAST (#595). The preflight checks what scaffolding + # and the doctor need, and its verdict is the exit status below. + # git-filter-repo is needed to adopt and for nothing else, so it never + # changes the exit status: `adoption:` says where it stands, with the + # command to run next where it is missing, and `done:` says "ready" only + # when it is there as well. + say "adoption: $ADOPTION" + [ "$ADOPTION_READY" -eq 1 ] || + machine="this machine is ready to scaffold, not yet to adopt" + # 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. @@ -394,10 +469,10 @@ main() { ;; 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)." + say "done: openRepoShape $version is installed at $target and $machine, but the command does not stay on $version (see above)." exit 1 fi - 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." + say "done: openRepoShape $version is installed at $target, pinned to the release tag, and $machine. Run the same line again to upgrade to the newest release." exit 0 } diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index 34c7de5a..c118d18e 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -463,7 +463,7 @@ def pipx_failing(net): def no_pipx(net): (net.path / "pipx").unlink() - return f" python3 -m pip install --user {FILTER_REPO}" + return f" uv tool install {FILTER_REPO}" def no_pipx_but_brew(net): @@ -476,7 +476,8 @@ def no_pipx_but_brew(net): 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}" + return f" sudo apt-get install -y pipx && pipx install {FILTER_REPO} " \ + "&& pipx ensurepath" @pytest.mark.parametrize("arrange", [ @@ -487,11 +488,13 @@ def test_git_filter_repo_is_said_installed_or_printed(net, arrange): 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: 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. + CI's version, then what came of it. NEITHER PIPX NOR uv: the one command + for this 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 uv's otherwise, never pip's (#595; uv, dnf, pacman and the + summary are `tests/test_install_sh_filter_repo.py`'s). 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() @@ -510,10 +513,10 @@ def test_git_filter_repo_is_said_installed_or_printed(net, arrange): else: assert ran_pipx == [], net.calls() 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[lines.index(expected) - 1].startswith( + "git-filter-repo: not installed, and there is neither pipx nor uv " + "to install it with. Only adopt-project.py needs it; before " + "adopting a repository, ") assert lines.index(expected) < lines.index( f"preflight: {net.bin}/openRepoShape --preflight") assert not any(call.split()[0] in ("apt-get", "brew", "sudo") diff --git a/tests/test_install_sh_filter_repo.py b/tests/test_install_sh_filter_repo.py new file mode 100644 index 00000000..2610c97a --- /dev/null +++ b/tests/test_install_sh_filter_repo.py @@ -0,0 +1,409 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`install.sh`'s git-filter-repo step, and the two readinesses it ends with +(#595). + +THE DEFECT, ON RAVEN (Arch Linux, `uv` and no `pipx`). The v1.0.1 installer +found no pipx, printed `python3 -m pip install --user git-filter-repo==2.47.0`, +which Arch's externally managed Python refuses (PEP 668), and ended "this +machine is ready", because the preflight does not ask about git-filter-repo. +The next `adopt-project.py execute` refused for the want of it. + +WHAT IS TRUE NOW, each held below: + +* git-filter-repo is installed with the pipx that is already here, or with + the uv that is already here when there is no pipx, at the version CI runs: + one attempt, with one tool. Installed means found on `$PATH` afterwards, + and a directory that is not on it yet is named with the tool's own way to + put it there; +* with neither, the command printed is never `pip install --user`: brew's, + pipx from apt or dnf, Arch's own package, or uv's once uv is installed, and + nothing is run; +* the run ends with two readinesses, `adoption:` and then `done:`, which says + "ready" only when git-filter-repo is there and "ready to scaffold, not yet + to adopt" when it is not, with the next step on the line above; +* every next step names its command so that it pastes and runs as printed + (the review on #598): this file pastes it into bash, every program it + names a logging fake, and holds the calls it makes; +* git-filter-repo never changes the exit status, which stays the preflight's + and the pin's: it is needed to adopt and for nothing else. + +THE FAKES ARE `tests/test_install_sh.py`'s `Net`, whose `pipx` places a +`git-filter-repo` where `net.json` says, and whose other names log the call +and exit 0. This file adds one program, `INSTALLERS`, behind `uv`: `uv tool +install` places a `git-filter-repo` where `uv_installs_into` says, or fails +with `uv_exit` (1 by default) when it names nowhere. Every case also has +`sudo`, `python3`, `python`, `pip` and `pip3` on `$PATH`, so a run that asked +any of them for anything is in `calls.log`, and none may be. +""" + +from __future__ import annotations + +import re +import shlex +import sys + +import pytest + +import test_install_sh +from test_install_sh import FILTER_REPO, RELEASE, Net, preflight_call + +#: The skip and the set-up of the file whose fakes these are, so the one +#: installer's tests run, or do not, together. +pytestmark = test_install_sh.pytestmark + +#: `uv`, as `install.sh` asks it: `uv tool install` places a `git-filter-repo` +#: launcher where `uv_installs_into` names, with `\n` line ends whatever the +#: platform, or fails as a resolver or a network would, with `uv_exit`. +#: Logged first, into the same `calls.log` as every other fake. +INSTALLERS = r''' +import json +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") +if ARGS[:2] == ["tool", "install"] and NET.get("uv_installs_into"): + placed = Path(NET["uv_installs_into"]) / "git-filter-repo" + placed.write_bytes(b"#!/bin/sh\nexit 0\n") + placed.chmod(0o755) + sys.exit(0) +sys.stderr.write("fake uv: could not run " + " ".join(ARGS) + "\n") +sys.exit(NET.get("uv_exit", 1)) +''' + +#: Every way to reach pip, and `sudo`: on `$PATH` in every case, logging, +#: and never run. +PIP_AND_SUDO = ("sudo", "python3", "python", "pip", "pip3") +#: Those, and the package managers the printed commands name, each on `$PATH` +#: in its own case: nothing here may run any of them. +NEVER_RUN = (*PIP_AND_SUDO, "apt-get", "dnf", "pacman", "brew") +#: Every program a printed next step names: each a logging fake on `$PATH` +#: while this file pastes that step. +NAMED = ("sudo", "pipx", "uv", "brew", "apt-get", "dnf", "pacman") + +UV_PAGE = "https://docs.astral.sh/uv/getting-started/installation/" + +#: The `adoption:` line where git-filter-repo is found. +READY = ("adoption: git-filter-repo is installed, which adopt-project.py " + "needs beyond what the preflight checks.") + +#: How the `done:` line ends for each readiness, after the path it names. +DONE_READY = (", pinned to the release tag, and this machine is ready. Run " + "the same line again to upgrade to the newest release.") +DONE_SCAFFOLD_ONLY = (", pinned to the release tag, and this machine is ready " + "to scaffold, not yet to adopt. Run the same line again " + "to upgrade to the newest release.") + + +def not_ready(why: str, then: str) -> str: + """The `adoption:` line where git-filter-repo is not found: `why`, and + `then`, what to do before adopting.""" + return ("adoption: NOT READY: adopt-project.py needs git-filter-repo, and " + f"{why}. Before adopting a repository, {then}") + + +def installing(tool: str, command: str) -> str: + return (f"git-filter-repo: not installed; installing it with {tool}, " + f"because adopt-project.py needs it: {command}") + + +def could_not(tool: str, status: int, command: str) -> str: + return (f"git-filter-repo: {tool} could not install it (exit {status}). " + f"Before adopting a repository, install it: {command}") + + +def off_path(tool: str, ensure: str) -> str: + return (f"git-filter-repo: installed with {tool}, into a directory that " + f"is not on PATH yet: run `{ensure}` and open a new shell") + + +#: How step 3 opens where there is neither installer; what follows it is the +#: case's own `run:`, or uv's page first. +NEITHER = ("git-filter-repo: not installed, and there is neither pipx nor uv " + "to install it with. Only adopt-project.py needs it; before " + "adopting a repository, ") + +PIPX = f"pipx install {FILTER_REPO}" +UV = f"uv tool install {FILTER_REPO}" +#: What comes before uv's command where there is neither installer nor a +#: package manager `install.sh` knows: uv itself, from its own page. +UV_FIRST = f"install uv ({UV_PAGE}), then run:" +PACMAN = "sudo pacman -S --needed git-filter-repo" + + +def apt_or_dnf(manager: str) -> str: + return f"sudo {manager} install -y pipx && {PIPX} && pipx ensurepath" + + +def uv(net: Net) -> None: + """A `uv` on the run's `$PATH`, answering through `INSTALLERS`.""" + program = net.root / "installers.py" + program.write_text(INSTALLERS, encoding="utf-8") + (net.path / "uv").write_bytes(( + f"#!/bin/sh\nexec {shlex.quote(sys.executable)} " + f"{shlex.quote(str(program))} uv \"$@\"\n").encode("utf-8")) + (net.path / "uv").chmod(0o755) + + +def elsewhere(net: Net) -> str: + """A directory that is not on the run's `$PATH`.""" + directory = net.tmp_path / "tool-bin" + directory.mkdir(exist_ok=True) + return str(directory) + + +# --- the cases: what is on the machine, and what the run must say ------------ +# +# Each takes a `Net` with `curl` and `gh` and nothing else answerable, puts +# what its case needs on it, and returns four things: the lines step 3 prints +# (each the start of a line, in order), the installers it runs, the +# `adoption:` line, and the calls its next step makes when pasted (None where +# there is no next step). + +def existing(net): + """Already on PATH: said, and neither installer, both there, is run.""" + net.fake("pipx") + uv(net) + net.fake("git-filter-repo") + return ["git-filter-repo: already installed ("], [], READY, None + + +def existing_as_git_subcommand(net): + net.fake("git") + net.config["git_filter_repo"] = True + return ["git-filter-repo: already installed (git filter-repo --version " + "answers)"], [], READY, None + + +def pipx_only(net): + net.fake("pipx") + return [installing("pipx", PIPX), + "git-filter-repo: installed with pipx"], [PIPX], READY, None + + +def uv_only(net): + """Raven's machine: uv, and no pipx.""" + uv(net) + net.config["uv_installs_into"] = str(net.path) + return [installing("uv", UV), "git-filter-repo: installed with uv"], \ + [UV], READY, None + + +def pipx_and_uv(net): + """pipx first, as before #595; uv is not asked.""" + net.fake("pipx") + uv(net) + net.config["uv_installs_into"] = str(net.path) + return [installing("pipx", PIPX), + "git-filter-repo: installed with pipx"], [PIPX], READY, None + + +def pipx_failing(net): + net.fake("pipx") + net.config["pipx_installs_into"] = None + return [installing("pipx", PIPX), could_not("pipx", 1, PIPX)], [PIPX], \ + not_ready("pipx could not install it (exit 1)", f"run: {PIPX}"), \ + [PIPX] + + +def pipx_failing_uv_there(net): + """One attempt, with one tool: pipx fails, and the uv beside it, which + would have installed it, is not asked.""" + net.fake("pipx") + net.config["pipx_installs_into"] = None + uv(net) + net.config["uv_installs_into"] = str(net.path) + return [installing("pipx", PIPX), could_not("pipx", 1, PIPX)], [PIPX], \ + not_ready("pipx could not install it (exit 1)", f"run: {PIPX}"), \ + [PIPX] + + +def uv_failing(net): + """uv's own exit status, 2 here, is the one said.""" + uv(net) + net.config["uv_exit"] = 2 + return [installing("uv", UV), could_not("uv", 2, UV)], [UV], \ + not_ready("uv could not install it (exit 2)", f"run: {UV}"), [UV] + + +def pipx_off_path(net): + net.fake("pipx") + net.config["pipx_installs_into"] = elsewhere(net) + return [installing("pipx", PIPX), off_path("pipx", "pipx ensurepath")], \ + [PIPX], not_ready("pipx installed it into a directory that is not on " + "PATH yet", "run `pipx ensurepath`, then open a new " + "shell"), ["pipx ensurepath"] + + +def uv_off_path(net): + uv(net) + net.config["uv_installs_into"] = elsewhere(net) + return [installing("uv", UV), off_path("uv", "uv tool update-shell")], \ + [UV], not_ready("uv installed it into a directory that is not on " + "PATH yet", "run `uv tool update-shell`, then open a " + "new shell"), ["uv tool update-shell"] + + +def neither(manager, command, pasted, first="run:"): + """Neither installer, and `manager` the one package manager on PATH: + `command` printed after `first`, making `pasted` when pasted.""" + def arrange(net): + if manager: + net.fake(manager) + return [f"{NEITHER}{first}", f" {command}"], [], \ + not_ready("it is not installed", f"{first} {command}"), pasted + arrange.__name__ = f"neither_{manager or 'and_no_package_manager'}" + return arrange + + +NEITHER_ANYWHERE = neither("", UV, [UV], UV_FIRST) + +CASES = [ + existing, existing_as_git_subcommand, pipx_only, uv_only, pipx_and_uv, + pipx_failing, pipx_failing_uv_there, uv_failing, pipx_off_path, + uv_off_path, NEITHER_ANYWHERE, + neither("brew", "brew install git-filter-repo", + ["brew install git-filter-repo"]), + neither("apt-get", apt_or_dnf("apt-get"), + ["sudo apt-get install -y pipx", PIPX, "pipx ensurepath"]), + neither("dnf", apt_or_dnf("dnf"), + ["sudo dnf install -y pipx", PIPX, "pipx ensurepath"]), + neither("pacman", PACMAN, [PACMAN]), +] + + +def arranged(tmp_path, arrange): + """A `Net` with no installer on it, every `PIP_AND_SUDO` name logging, + and `arrange`'s case put on it.""" + net = Net(tmp_path, fakes=("curl", "gh")) + for name in PIP_AND_SUDO: + net.fake(name) + return (net, *arrange(net)) + + +def starts_in_order(lines, starts): + """The index of the first line starting with each of `starts`, which + must exist and come in that order.""" + at = [] + for start in starts: + found = [i for i, line in enumerate(lines) if line.startswith(start)] + assert found, f"no line starts {start!r}:\n" + "\n".join(lines) + at.append(found[0]) + assert at == sorted(at), "\n".join(lines) + return at + + +def next_command(adoption: str) -> str: + """The command an `adoption: NOT READY` line says to run, as a person + copies it: between the backticks of ``run `…` ``, or after `run: ` to + the end of the line.""" + ticked = re.search(r"run `([^`]+)`", adoption) + return ticked.group(1) if ticked else adoption.split("run: ", 1)[1] + + +def paste(net: Net, command: str) -> list: + """The calls `command` makes, pasted into bash on the run's `$PATH` with + every program in `NAMED` a logging fake there.""" + for name in NAMED: + if not (net.path / name).exists(): + net.fake(name) + before = len(net.calls()) + net.run(script=command + "\n") + return net.calls()[before:] + + +@pytest.mark.parametrize("arrange", CASES, ids=lambda case: case.__name__) +def test_git_filter_repo_and_the_two_readinesses(tmp_path, arrange): + """Every acceptance case of #595, held to the same six things. + + Step 3 says what it found and what it did, before the preflight. The + installers run are exactly the case's: pipx where it is there, uv where + pipx is not, neither where git-filter-repo already is, each once, at + CI's version, and nothing in `NEVER_RUN` at all. No line names `pip + install` or `--user`. The run ends `adoption:` then `done:`, both after + the preflight, and the `done:` line says "ready" only when the + `adoption:` line does. The exit status is 0 in every case, because the + preflight passed: git-filter-repo is not needed to scaffold. And the + next step's command, copied off the `adoption:` line and pasted, makes + exactly the calls it names, with no `#` to become an argument in a shell + that does not read comments (an interactive zsh, by default). + """ + net, steps, ran, adoption, pasted = arranged(tmp_path, arrange) + result = net.run() + assert result.returncode == 0, result.stdout + result.stderr + net.installed(result) + lines = result.stdout.splitlines() + + at = starts_in_order(lines, [*steps, "preflight: "]) + + calls = net.calls() + assert [call for call in calls + if call.split()[0] in ("pipx", "uv")] == ran, calls + assert not any(call.split()[0] in NEVER_RUN for call in calls), calls + assert calls[-1] == preflight_call(), calls + + assert "pip install" not in result.stdout + result.stderr + assert "--user" not in result.stdout + result.stderr + + assert lines[-2] == adoption, result.stdout + done = DONE_READY if adoption == READY else DONE_SCAFFOLD_ONLY + assert lines[-1].startswith(f"done: openRepoShape {RELEASE} is installed " + "at "), result.stdout + assert lines[-1].endswith(done), result.stdout + assert lines.index(adoption) > at[-1] + + if pasted is not None: + command = next_command(adoption) + assert "#" not in command, command + assert paste(net, command) == pasted + + +@pytest.mark.parametrize("arrange", [existing, NEITHER_ANYWHERE], + ids=["git-filter-repo-there", "git-filter-repo-not"]) +def test_a_preflight_that_fails_is_its_own_verdict_either_way( + tmp_path, arrange): + """The two readinesses are two: a preflight that did not pass exits 1 + with git-filter-repo there or not, and the `adoption:` line still says + which, above the `done:` line that says the preflight did not pass.""" + net, _, _, adoption, _ = arranged(tmp_path, arrange) + net.config["preflight_exit"] = 1 + result = net.run() + assert result.returncode == 1, result.stdout + result.stderr + lines = result.stdout.splitlines() + assert lines[-2] == adoption, result.stdout + assert lines[-1].endswith(", but the preflight did not pass (exit 1); it " + "names what is missing above."), result.stdout + + +#: A command older than v1.0.1, as far as the installer can tell, that places +#: itself where the real one does by default and records no ref. +UNPINNED = """#!/usr/bin/env bash +INSTALLABLES=(openRepoShape) +case "$1" in +--install) mkdir -p "$HOME/.local/bin" && cp "$0" "$HOME/.local/bin/openRepoShape" ;; +--version) printf 'openRepoShape (opensoft/openRepoShape @ main)\\n' ;; +--preflight) printf 'old preflight\\n' ;; +esac +""" + + +def test_a_command_that_does_not_stay_on_the_release_says_both_readinesses( + tmp_path): + """The `done:` line for a release that records no ref carries the + machine's readiness too, and says "ready to scaffold, not yet to adopt" + where git-filter-repo is missing; the exit status is the pin's, 1.""" + net, _, _, adoption, _ = arranged(tmp_path, + neither("pacman", PACMAN, [PACMAN])) + old = net.root / "unpinned-openRepoShape" + old.write_bytes(UNPINNED.encode("utf-8")) + net.serve(RELEASE, launcher=old) + result = net.run() + assert result.returncode == 1, result.stdout + result.stderr + lines = result.stdout.splitlines() + assert lines[-2] == adoption, result.stdout + assert lines[-1].endswith( + " and this machine is ready to scaffold, not yet to adopt, but the " + f"command does not stay on {RELEASE} (see above)."), result.stdout