diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 62deb803..5fc46de7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,9 +4,13 @@ name: tests # clones them over the filesystem. It never calls `gh`, never touches the # network, and must never create a real repository. # -# The suite runs on LINUX ONLY, on every trigger: a push to `main`, a pull +# The FULL suite runs on LINUX ONLY, on every trigger: a push to `main`, a pull # request and a manual run all use the one ubuntu job below. Brett Heap decided # on 2026-10-15, for the whole estate, to drop the macOS and Windows test jobs. +# One exception, on Brett Heap's word at 2026-10-15T18:20:35Z, +# "Option 1: keep a small Git Bash job for #596": the `tests-git-bash` job +# below runs the launcher's and the installer's suites, and nothing else, on +# Windows under Git Bash. on: push: @@ -123,3 +127,94 @@ jobs: .github/workflows/tests.yml templates/assembly-root/.github/workflows/validate.yml templates/family-root/.github/workflows/validate.yml + + # THE LAUNCHER'S AND THE INSTALLER'S SUITES ON WINDOWS, UNDER GIT BASH + # (#585). README says Git Bash runs the `openRepoShape` command and the + # one-line install, "tested in CI but for a typed `yes`", and this job is + # what makes that true: `tests/test_openreposhape_command.py` and + # `tests/test_install_sh.py`, run here as Git Bash runs them. + # + # TWO SUITES, NOT THE WHOLE SUITE. #597 dropped the Windows job for the + # whole estate, and this job is the one exception made to it (see the + # header), so it runs what README's sentence claims and nothing more: + # about a minute and a half (86 s on the first run, 50 s of it pytest), + # beside the ubuntu job and not after it. No git-filter-repo, because + # these suites never reach a real one (the install suite fakes it and + # pipx, and the launcher's machine report gets a fake on the PATH it + # builds), and no actionlint, which the ubuntu job runs on this file. + # + # THE BASH IS GIT'S OWN `usr\bin\bash.exe`, which `tests/conftest.py` + # (`BASH`) finds from `git --exec-path` and never by name: a bare `bash` + # runs System32's WSL launcher, which CreateProcess searches before PATH, + # and `Git\bin\bash.exe`, the `bash` PATH names first on this image, puts + # Git's real `curl` ahead of the suites' fake. conftest's comment there + # has the rest. + # + # ON EVERY TRIGGER, a push to `main` included: a squash lands a tree no + # pull-request run tested (#122), and these two suites are the only run + # of the one-line install on Windows. + tests-git-bash: + runs-on: windows-latest + timeout-minutes: 15 + env: + # The old Windows job's settings, which every Windows run of these + # two suites has had, #585's probes included. Per-invocation git + # config, never `git config --global`. + # + # PYTHONUTF8=1 (PEP 540): the suites read bash's output with + # `text=True`, which decodes in the locale's code page (cp1252 here) + # outside UTF-8 mode, and the launcher's `--help` is full of em dashes. + # + # core.autocrlf=false: Git for Windows' own default is `true`, and the + # suites commit, clone and check out repositories of their own; no test + # here asks for line-ending translation. + # + # core.longpaths=true: MAX_PATH is 260 characters, and the suites' + # clones nest under pytest's temp root. + PYTHONUTF8: '1' + GIT_CONFIG_COUNT: '2' + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: 'false' + GIT_CONFIG_KEY_1: core.longpaths + GIT_CONFIG_VALUE_1: 'true' + steps: + # The SAME two commits the ubuntu job pins. `fetch-depth: 0` because + # the launcher's suite clones this checkout itself + # (`OPENREPOSHAPE_REPO` in `test_a_ref_the_doctor_would_check_out_is_ + # validated`), and every Windows run of it has cloned a full one. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + # `python`, not `python3`: a Windows install has no `python3`. + - name: install the test runner + run: python -m pip install --disable-pip-version-check pytest==8.3.4 + + # Per-invocation identity, never `git config --global`. PowerShell is + # the default shell on this runner, so the append is + # `>> $env:GITHUB_ENV` rather than the ubuntu job's + # `{ echo ... } >> "$GITHUB_ENV"` block. + - name: git identity for the test repositories (env, not global config) + run: | + "GIT_AUTHOR_NAME=openRepoShape CI" >> $env:GITHUB_ENV + "GIT_AUTHOR_EMAIL=ci@openreposhape.invalid" >> $env:GITHUB_ENV + "GIT_COMMITTER_NAME=openRepoShape CI" >> $env:GITHUB_ENV + "GIT_COMMITTER_EMAIL=ci@openreposhape.invalid" >> $env:GITHUB_ENV + + # GIT BASH, OR THIS JOB FAILS HERE. Where conftest finds no Git Bash, + # both suites SKIP, and a job of skips would leave README's sentence a + # claim again, so this step refuses first and prints the bash the + # suites will run. + - name: Git Bash is the bash the launcher and installer suites run + run: python -c "import sys; sys.path.insert(0, 'tests'); from conftest import BASH; print(BASH); sys.exit(BASH is None)" + + # `-rA` names every test and its result in the log. `--basetemp` puts + # pytest's temp root as near the drive root as a path the runner + # guarantees allows: `runner.temp` is `D:\a\_temp` or `C:\a\_temp`, + # and the default profile path is ~50 of MAX_PATH's 260 characters + # before the suites' own nesting starts. + - name: the launcher's and the installer's suites, under Git Bash + run: python -m pytest -q -rA tests/test_openreposhape_command.py tests/test_install_sh.py --basetemp=${{ runner.temp }}\t diff --git a/README.md b/README.md index 2664a842..12c3e498 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,9 @@ puts it there — run that line, or the next shell cannot find `brew`. Then `bre 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`, `install.sh` and the `openRepoShape` command are -written to use no syntax newer than that, though CI runs on Linux only and no -run of it checks them against bash 3.2. That the login shell is zsh changes -nothing — all three entry points are run under `bash` explicitly. +written to use no syntax newer than that, though no CI run, on Linux or in Git +Bash on Windows, checks them against bash 3.2. That the login shell is zsh +changes nothing — all three entry points are run under `bash` explicitly. @@ -150,19 +150,19 @@ and refuses. Downloading the file first leaves stdin on your terminal. 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 `python3` — it runs the interpreter that started it. That two-liner IS the -Windows way in; the `openRepoShape` command in step 3 below is a macOS, Linux -and WSL2 convenience with no Windows twin, because on Windows there is nothing -to install. 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`, `add` and `git rm -r --cached` lines -are quoted for PowerShell, on Windows. +Windows way in from PowerShell, which has no twin of step 3's `openRepoShape` +command. Git Bash runs that command and step 3's one line, tested in CI but for +a typed `yes`, which its preflight cannot take there. 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`, +`add` and `git rm -r --cached` lines are quoted for PowerShell, on Windows.
Windows via WSL2 -**Windows via WSL2** is still how you run the bash entry points: `wsl +**Windows via WSL2** runs every bash entry point in Linux itself: `wsl --install` from an administrator PowerShell, reboot, open the Ubuntu app, and run the four steps below INSIDE the WSL shell. @@ -231,14 +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 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`, 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. +`install.sh`, attached to every release, 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`, 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. It never runs `sudo` itself; the preflight offers +what is missing, each behind a typed `yes` at your terminal; in Git Bash it can +take none. `| 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 801cc4ac..1f6e76d1 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -493,7 +493,7 @@

openRepoShape

several of them is held together by convention or by nothing. This repository is the convention, as data and as running code.

-

The fastest path — macOS, Linux, WSL2

+

The fastest path — macOS, Linux, WSL2, Git Bash

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 @@ -504,8 +504,8 @@

The fastest path — macOS, Linux, WSL2

in-flight work to another workstation, are opensoft/openRepoTools’ and have a one-liner of their own.

-

On Windows without WSL2 the way in is two PowerShell commands instead, - because on Windows there is nothing to install — see Start +

In PowerShell, without WSL2 or Git Bash, the way in is two commands + instead, because there is nothing to install — see Start here.

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

The one thing to read before electing it

- +

Setup

Start here

@@ -690,8 +690,8 @@

What this machine needs

repository rather than scaffolding a new one.

The stock /bin/bash is 3.2 and is enough: setup.sh, install.sh and the openRepoShape command are written to use no - syntax newer than that, though CI runs on Linux only and no run of it checks them against - bash 3.2. That the login shell is zsh changes nothing — all three entry points are + syntax newer than that, though no CI run, on Linux or in Git Bash on Windows, checks them + against bash 3.2. 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 @@ -721,9 +721,11 @@

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. + There is no openRepoShape command to install in PowerShell: that two-liner + IS the Windows way in there. Git Bash, the bash that Git for Windows installs, runs the + openRepoShape command and the one-line install, and CI tests both there, but + for a typed yes: CI has no terminal, and in Git Bash the preflight that the + one-line install ends with cannot take one, so none of its offers can be accepted there. 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, @@ -737,7 +739,7 @@

The tool offers the rest

What this machine needs

-

WSL2 is still how you run the bash entry points: wsl --install from an +

WSL2 runs every bash entry point in Linux itself: wsl --install from an administrator PowerShell, reboot, open the Ubuntu app, and run the steps INSIDE the WSL shell.

wsl --install
@@ -973,7 +975,8 @@

Consent rules, on every platform

asks nothing before its own two acts, because running it is your act: it places the 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. + preflight it ends with asks before each offer, as above; in Git Bash it can take no + yes, so it installs nothing.
  • --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.
  • @@ -1045,7 +1048,7 @@

    What you have afterwards — the same on every platform

    - +

    Setup

    If it fails part-way

    @@ -1080,7 +1083,7 @@

    If it fails part-way

    - + @@ -1138,7 +1141,7 @@

    Reading private legs in CI

    - + @@ -1374,7 +1377,7 @@

    The InkRouter example

    - + @@ -1671,7 +1674,7 @@

    Is this repository compliant? openRepoShape --doctor

    - + diff --git a/tests/conftest.py b/tests/conftest.py index 66b0af5e..e78386a4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,7 @@ from __future__ import annotations +import functools import importlib.util import os import re @@ -225,6 +226,102 @@ def load_without_bytecode(path: Path, name: str): "point is setup-project.py (tests/test_setup_project_py.py)") +def _git_for_windows() -> Path | None: + """Git for Windows' own directory, from the `git` that runs this suite. + + `git --exec-path` answers `/mingw64/libexec/git-core` there, and + `/usr/bin/bash.exe` is its bash. None off Windows, and where that + git is some other build, with no such bash beside it. + """ + if os.name != "nt": + return None + try: + answer = subprocess.run(["git", "--exec-path"], capture_output=True, + text=True, check=False).stdout.strip() + except OSError: + return None + parents = Path(answer).parents if answer else () + if len(parents) < 3: + return None + root = parents[2] + return root if (root / "usr" / "bin" / "bash.exe").is_file() else None + + +GIT_FOR_WINDOWS = _git_for_windows() + +#: THE BASH THAT `openRepoShape` AND `install.sh` ARE TESTED UNDER (#585). +#: Off Windows, the `bash` on PATH. On Windows, Git Bash: Git for Windows' +#: own `usr\bin\bash.exe`, found beside the git this suite runs and NEVER +#: from PATH, because a bare `bash` there can be `System32\bash.exe`, the WSL +#: launcher, and `subprocess` looks in System32 before it reads PATH at all. +#: `usr\bin` and not `bin`: `bin\bash.exe`, which Git Bash's own shortcut +#: runs, puts Git's `mingw64\bin` and `usr\bin` FIRST on PATH, ahead of a +#: test's fake `curl`, and a test's own PATH of a few programs would hold +#: every one of Git's. `git_bash_session` puts the two there behind the +#: fakes instead. These suites wore `WINDOWS_SKIP` until then. +if os.name == "nt": + BASH = (str(GIT_FOR_WINDOWS / "usr" / "bin" / "bash.exe") + if GIT_FOR_WINDOWS is not None else None) +else: + BASH = shutil.which("bash") + +BASH_SKIP = pytest.mark.skipif( + BASH is None, + reason="needs bash: on Windows, Git for Windows' own usr\\bin\\bash.exe " + "beside the git on PATH, which this machine does not have") + + +@functools.lru_cache(maxsize=None) +def _cygpath(flag: str, path: str) -> str: + """Git for Windows' `cygpath `: the spelling bash itself uses.""" + return subprocess.run( + [str(GIT_FOR_WINDOWS / "usr" / "bin" / "cygpath.exe"), flag, "--", + path], capture_output=True, text=True, check=True).stdout.strip() + + +def bash_path(path) -> str: + """`path` as `BASH` spells it, for its argv, its environment and what it + prints (#585): unchanged off Windows, and `/d/a/...` under Git Bash, from + its own `cygpath -u` — the spelling Git Bash gives `$HOME`, `$TMPDIR`, + `PATH` and `pwd`, and so the one a person there types.""" + if GIT_FOR_WINDOWS is None: + return str(path) + return _cygpath("-u", str(path)) + + +def native_path(text: str) -> Path: + """The other way: a path `BASH` printed, as this interpreter opens it.""" + if GIT_FOR_WINDOWS is None: + return Path(text) + return Path(_cygpath("-w", text)) + + +@pytest.fixture +def git_bash_session(monkeypatch): + """PATH and `MSYSTEM` as Git Bash makes them, for a test that runs + `BASH` (#585); off Windows, nothing. + + Git Bash puts Git's `mingw64\\bin` and `usr\\bin` ahead of the Windows + PATH, which is where a person there finds `mktemp`, `cp` and the bash + that `openRepoShape` runs `setup.sh` with, ahead of System32's WSL + launcher. Here they go ahead of `os.environ["PATH"]`, so a fake a test + puts first still comes first. Neither directory holds a `gh`, so the + session's gh guard still answers. The GitHub runner's own PATH already + holds both, and Git's `bin` before System32; Git for Windows' default + install puts only its `cmd` on PATH, and there, without this, `mktemp` + is not found and `bash` is the WSL launcher. + """ + if GIT_FOR_WINDOWS is None: + return + # Git for Windows on ARM64 ships `clangarm64` where x64 has `mingw64`. + msystem = "clangarm64" if (GIT_FOR_WINDOWS / "clangarm64").is_dir() \ + else "mingw64" + monkeypatch.setenv("PATH", os.pathsep.join([ + str(GIT_FOR_WINDOWS / msystem / "bin"), + str(GIT_FOR_WINDOWS / "usr" / "bin"), os.environ.get("PATH", "")])) + monkeypatch.setenv("MSYSTEM", msystem.upper()) + + def rmtree(path: Path) -> None: """`shutil.rmtree` that also works over a git object store on Windows. diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index c118d18e..dba92000 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -24,14 +24,18 @@ `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`. +On Windows it runs under Git Bash, as `tests/test_openreposhape_command.py` +does (#585): `BASH` is Git for Windows' own, every path is spelled the way +Git Bash spells it, and every script a fake writes has `\n` line ends. What +a fake prints that bash reads into a variable, the redirect's headers and +`gh`'s tag, is written as bytes, because Python on Windows ends a printed +line with `\r\n` and `$(…)` keeps the `\r`. """ from __future__ import annotations import json +import os import re import shlex import shutil @@ -41,17 +45,15 @@ import pytest -from conftest import REPO, WINDOWS_SKIP -from test_openreposhape_command import COMMAND, INSTALL_NEEDS, command_env +from conftest import BASH, BASH_SKIP, REPO, bash_path +from test_openreposhape_command import (COMMAND, INSTALL_NEEDS, command_env, + put_on_path, write_script) 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] +#: `BASH` is 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. +pytestmark = [BASH_SKIP, pytest.mark.usefixtures("git_bash_session")] SLUG = "opensoft/openRepoShape" RELEASE = "v9.9.9" @@ -101,9 +103,9 @@ def refuse(message): 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.write_bytes(("#!/bin/sh\nexec %s %s %s \"$@\"\n" % ( + shlex.quote(sys.executable), shlex.quote(str(Path(__file__))), + name)).encode("utf-8")) path.chmod(0o755) @@ -116,9 +118,10 @@ def curl(): 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\n%s: %s\r\n" - "content-length: 0\r\n\r\n" - % (NET.get("location_header", "location"), location)) + sys.stdout.buffer.write(( + "HTTP/2 302 \r\n%s: %s\r\ncontent-length: 0\r\n\r\n" + % (NET.get("location_header", "location"), location) + ).encode("utf-8")) return 0 served = NET["files"].get(url) if served is None: @@ -134,7 +137,7 @@ def curl(): def gh(): if ARGS[:1] == ["api"] and ARGS[1].endswith("/releases/latest") \ and NET.get("gh_latest"): - print(NET["gh_latest"]) + sys.stdout.buffer.write((NET["gh_latest"] + "\n").encode("utf-8")) return 0 return refuse("fake gh: no answer for: " + " ".join(ARGS)) @@ -195,7 +198,7 @@ def __init__(self, tmp_path: Path, fakes=("curl", "gh", "pipx")): 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) + put_on_path(self.path, name, real) for name in fakes: self.fake(name) self.temporary = tmp_path / "tmp" @@ -208,10 +211,9 @@ def __init__(self, tmp_path: Path, fakes=("curl", "gh", "pipx")): 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") + write_script( + path, f"#!/bin/sh\nexec {shlex.quote(sys.executable)} " + f"{shlex.quote(str(self.root / 'fake.py'))} {name} \"$@\"\n") path.chmod(0o755) return path @@ -232,18 +234,30 @@ def run(self, *args: str, piped: bool = False, env: dict | None = None, 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=(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", - "GH_CONFIG_DIR": str(gh_config), - "TMPDIR": str(self.temporary), **(env or {})})) + how = {"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 {})})} + if piped or script is not None: + return subprocess.run( + [BASH, "-s", "--", *args], capture_output=True, text=True, + check=False, + input=script or INSTALLER.read_text(encoding="utf-8"), **how) + argv = [BASH, bash_path(INSTALLER), *args] + if os.name == "nt" and any("\n" in arg for arg in args): + # A NEWLINE IN AN ARGUMENT DOES NOT CROSS INTO GIT BASH (#585). + # Windows hands a program one command line, Python quotes only + # an argument holding a space or a tab, and Git Bash splits the + # line at a newline too, so `v1\nforged` arrived as two + # versions. A person in Git Bash passes it from bash to bash, + # where nothing is split; here a bash takes the arguments from + # the environment, which keeps them whole, and hands them on. + how["env"].update((f"ARG{at}", arg) for at, arg in enumerate(args)) + argv = [BASH, "-c", 'exec "$BASH" "$0" ' + " ".join( + f'"$ARG{at}"' for at in range(len(args))), argv[1]] + return subprocess.run(argv, capture_output=True, text=True, + check=False, input="", **how) def calls(self) -> list: log = self.root / "calls.log" @@ -292,7 +306,7 @@ def test_the_one_line_installs_the_newest_release_pinned_to_it(net): result = net.run(piped=True) assert result.returncode == 0, result.stdout + result.stderr net.installed(result) - target = net.bin / "openRepoShape" + target = bash_path(net.bin / "openRepoShape") steps = [ f"release: {RELEASE}, the newest release of {SLUG} ({latest()})", f"fetch: {raw('openRepoShape')}", @@ -433,7 +447,8 @@ def test_a_page_that_is_not_the_command_is_refused(net): def present_on_path(net): net.fake("git-filter-repo") - return f"git-filter-repo: already installed ({net.path}/git-filter-repo)" + return "git-filter-repo: already installed " \ + f"({bash_path(net.path)}/git-filter-repo)" def answering_git(net): @@ -518,7 +533,7 @@ def test_git_filter_repo_is_said_installed_or_printed(net, arrange): "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") + f"preflight: {bash_path(net.bin)}/openRepoShape --preflight") assert not any(call.split()[0] in ("apt-get", "brew", "sudo") for call in net.calls()), net.calls() @@ -540,7 +555,7 @@ def test_a_preflight_that_does_not_pass_exits_one_with_the_command_placed( assert result.returncode == 1, result.stdout + result.stderr net.installed(result) assert f"done: openRepoShape {RELEASE} is installed at " \ - f"{net.bin}/openRepoShape, {said}" in result.stdout + f"{bash_path(net.bin)}/openRepoShape, {said}" in result.stdout def test_a_variable_in_the_shell_is_named_and_the_preflight_runs_the_pin(net): @@ -564,10 +579,10 @@ def test_the_same_line_again_changes_nothing_and_then_upgrades(net): 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 "openRepoShape: already installed at " \ + f"{bash_path(net.bin)}/openRepoShape (unchanged)" in again.stdout assert f"openRepoShape: already pinned to {RELEASE} in " \ - f"{net.bin}/openRepoShape.ref (unchanged)" in again.stdout + f"{bash_path(net.bin)}/openRepoShape.ref (unchanged)" in again.stdout net.config["redirects"][latest()] = tag_page("v10.0.0") net.serve("v10.0.0") @@ -575,7 +590,7 @@ def test_the_same_line_again_changes_nothing_and_then_upgrades(net): 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 + f"{bash_path(net.bin)}/openRepoShape.ref" in upgrade.stdout def test_a_release_whose_command_records_no_ref_exits_one(net): @@ -584,12 +599,13 @@ def test_a_release_whose_command_records_no_ref_exits_one(net): 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") + write_script(old, OLD_LAUNCHER) net.serve(RELEASE, launcher=old) - result = net.run(env={"OPENREPOSHAPE_BIN_DIR": str(net.bin), + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": bash_path(net.bin), "OPENREPOSHAPE_REPO": SLUG}) assert result.returncode == 1, result.stdout + result.stderr - assert f"install: {net.bin}/openRepoShape does not stay on {RELEASE}: " \ + assert f"install: {bash_path(net.bin)}/openRepoShape does not stay on " \ + f"{RELEASE}: " \ f"it says 'openRepoShape ({SLUG} @ main)' to --version." \ in result.stdout assert "A release older than v1.0.1 cannot record the tag it was " \ @@ -623,10 +639,10 @@ def test_openreposhape_bin_dir_is_where_the_command_and_its_record_go(net): 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)}) + result = net.run(env={"OPENREPOSHAPE_BIN_DIR": bash_path(net.bin)}) assert result.returncode == 0, result.stdout + result.stderr net.installed(result) - assert f"preflight: {net.bin}/openRepoShape --preflight" \ + assert f"preflight: {bash_path(net.bin)}/openRepoShape --preflight" \ in result.stdout.splitlines() assert not default.exists() @@ -704,7 +720,7 @@ def test_a_repository_that_is_not_an_owner_and_name_is_refused(net, value): def test_help_prints_the_one_line_and_exits_zero(tmp_path): result = subprocess.run( - [BASH, str(INSTALLER), "--help"], capture_output=True, text=True, + [BASH, bash_path(INSTALLER), "--help"], capture_output=True, text=True, check=False, input="", env=command_env(home=tmp_path, setup_sh=None, env={"GH_TOKEN": "bogus", diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index 5e495477..a91e3409 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -26,6 +26,7 @@ import os import re +import shlex import shutil import stat import subprocess @@ -34,7 +35,7 @@ import pytest -from conftest import REPO, WINDOWS_SKIP, git +from conftest import BASH, BASH_SKIP, REPO, bash_path, git, native_path COMMAND = REPO / "openRepoShape" SETUP = REPO / "setup.sh" @@ -74,14 +75,16 @@ #: about a different line. USAGE_SCAFFOLD = USAGE_LINES[3] -#: Same two reasons as `test_setup_sh.py`: no bash, or a Windows runner where -#: bash.exe exists and the `python3` the fetched `setup.sh` calls does not. -#: The command is a macOS/Linux/WSL2 convenience and has no Windows twin — -#: there the way in is `py setup-project.py`, which needs no command to fetch -#: it because it IS the file you downloaded. -pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, - reason="openRepoShape is a bash script"), - WINDOWS_SKIP] +#: Every run is `BASH`: the `bash` on PATH, or on Windows Git Bash, with the +#: PATH Git Bash makes (`git_bash_session`), every path spelled the way Git +#: Bash spells it (`bash_path`) and every script a test writes for it given +#: `\n` line ends (`write_script`) (#585). A `bash -s` run names `BASH` as +#: its `executable` and keeps `bash` as its argv[0], so `$0` is the word +#: `bash`, as a pipe into it gives. The suite wore `WINDOWS_SKIP` until +#: then, and the command its one-line install places was said to work in +#: Git Bash with nothing having run it there. PowerShell still has no twin: +#: there the way in is `py setup-project.py`, the file you downloaded. +pytestmark = [BASH_SKIP, pytest.mark.usefixtures("git_bash_session")] def command_env(home: Path | None = None, env: dict | None = None, @@ -102,7 +105,7 @@ def command_env(home: Path | None = None, env: dict | None = None, "OPENREPOSHAPE_RECORD_REF"): environ.pop(name, None) if setup_sh is not None: - environ["OPENREPOSHAPE_SETUP_SH"] = str(setup_sh) + environ["OPENREPOSHAPE_SETUP_SH"] = bash_path(setup_sh) environ.setdefault("GIT_AUTHOR_NAME", "openRepoShape tests") environ.setdefault("GIT_AUTHOR_EMAIL", "tests@openreposhape.invalid") environ.setdefault("GIT_COMMITTER_NAME", "openRepoShape tests") @@ -127,12 +130,45 @@ def run_cmd(*args: str, home: Path | None = None, env: dict | None = None, `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, - text=True, check=False, input="", - env=command_env(home, env, setup_sh), + return subprocess.run([BASH, bash_path(command), *args], + capture_output=True, text=True, check=False, + input="", env=command_env(home, env, setup_sh), start_new_session=True) +def write_script(path: Path, text: str) -> None: + """A script some bash will read, written with `\\n` line ends everywhere: + `write_text` writes `\\r\\n` on Windows (#585).""" + path.write_bytes(text.encode("utf-8")) + + +def assert_a_command(path: Path) -> None: + """`--install` placed a command: mode 755, or under Git Bash, which + keeps no mode bits (its drives are mounted `noacl`, so `chmod` changes + nothing there), one bash's own `-x` says it will run (#585).""" + if os.name != "nt": + assert stat.S_IMODE(path.stat().st_mode) == 0o755, path + return + probe = subprocess.run([BASH, "-c", 'test -x "$1"', "-", bash_path(path)], + capture_output=True, check=False) + assert probe.returncode == 0, f"bash will not run {path}" + + +def put_on_path(directory: Path, name: str, real: str) -> None: + """`directory/name` runs the program at `real`, for a `$PATH` a test + builds out of these and nothing else: a symlink, or on Windows a + `#!/bin/sh` script that execs it (#585). A symlink there needs an + elevated runner or Developer Mode, a copy of a Git Bash program cannot + find the DLL it loads from its own directory, and `/bin/sh` is found + through Git Bash's own mounts with nothing on `$PATH`.""" + link = directory / name + if os.name != "nt": + link.symlink_to(real) + return + write_script(link, "#!/bin/sh\nexec %s \"$@\"\n" + % shlex.quote(bash_path(real))) + + # --- what it says about itself --------------------------------------------- def test_help_prints_every_usage_line(): @@ -208,7 +244,7 @@ def test_install_writes_an_executable_copy(tmp_path): for name in INSTALLED: target = tmp_path / ".local" / "bin" / name assert target.is_file(), result.stdout + f" (missing {name})" - assert stat.S_IMODE(target.stat().st_mode) == 0o755, name + assert_a_command(target) assert target.read_bytes() == (REPO / name).read_bytes(), name assert f"{name}: installed at" in result.stdout assert f"openRepoShape: {len(INSTALLED)} of {len(INSTALLED)} placed" \ @@ -278,8 +314,9 @@ def test_install_points_at_openrepotools(tmp_path): log = tmp_path / "requests.log" served = fake_github(tmp_path, INSTALLED, log=log) result = subprocess.run( - ["bash", "-s", "--", "--install"], capture_output=True, text=True, - check=False, input=COMMAND.read_text(encoding="utf-8"), + ["bash", "-s", "--", "--install"], executable=BASH, + capture_output=True, text=True, check=False, + input=COMMAND.read_text(encoding="utf-8"), cwd=str(tmp_path), env=command_env(home=tmp_path, setup_sh=None, env={**served, @@ -305,7 +342,8 @@ def test_install_points_at_openrepotools(tmp_path): def test_install_says_how_to_put_it_on_path(tmp_path): result = run_cmd("--install", home=tmp_path) - assert f'export PATH="{tmp_path}/.local/bin:$PATH"' in result.stdout + assert f'export PATH="{bash_path(tmp_path)}/.local/bin:$PATH"' \ + in result.stdout def test_install_from_a_file_never_calls_gh(tmp_path): @@ -326,12 +364,11 @@ def test_install_from_a_file_never_calls_gh(tmp_path): # QUOTED for the reason `fake_github`'s log is: an unquoted path with a # space in it makes the redirection land somewhere else, and a witness # nobody can find is a test that passes without guarding. - (shim / "gh").write_text( - f"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{log}'\nexit 1\n", - encoding="utf-8") + write_script(shim / "gh", + f"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{log}'\nexit 1\n") (shim / "gh").chmod(0o755) result = run_cmd("--install", home=tmp_path, - env={"PATH": f"{shim}:{os.environ['PATH']}"}) + env={"PATH": f"{shim}{os.pathsep}{os.environ['PATH']}"}) assert result.returncode == 0, result.stderr called = log.read_text(encoding="utf-8").split("\n")[:-1] \ if log.exists() else [] @@ -436,8 +473,9 @@ def test_a_bare_run_fetches_nothing(tmp_path): log = tmp_path / "calls.log" served = fake_github(tmp_path, INSTALLED, log=log) result = subprocess.run( - ["bash", str(COMMAND)], capture_output=True, text=True, check=False, - input="", env=command_env(home=tmp_path, setup_sh=None, env=served)) + [BASH, bash_path(COMMAND)], capture_output=True, text=True, + check=False, input="", + env=command_env(home=tmp_path, setup_sh=None, env=served)) assert result.returncode == 1, result.stdout + result.stderr assert USAGE_LINES[0] in result.stdout assert not log.exists(), ( @@ -497,24 +535,25 @@ def machine_path(tmp_path, present, logged_in=True) -> dict: for name in INSTALL_NEEDS: real = shutil.which(name) assert real, f"this machine has no {name}, which --install needs" - link = binaries / name - if not link.exists(): - link.symlink_to(real) + if not (binaries / name).exists(): + put_on_path(binaries, name, real) for name in present: script = binaries / name if name == "gh": - script.write_text( + write_script( + script, "#!/bin/sh\n" f"printf 'gh %s\\n' \"$*\" >> '{log}'\n" 'case "$*" in "auth status") exit ' f'{0 if logged_in else 1};; esac\n' - "exit 1\n", encoding="utf-8") + "exit 1\n") else: - script.write_text( + write_script( + script, "#!/bin/sh\n" f"printf '{name} %s\\n' \"$*\" >> '{log}'\n" f"printf 'fake {name} was EXECUTED by a read-only report\\n' >&2\n" - "exit 1\n", encoding="utf-8") + "exit 1\n") script.chmod(0o755) return {"PATH": str(binaries), "_log": str(log)} @@ -527,7 +566,7 @@ def install_with_machine(tmp_path, present, logged_in=True): # The bash by ABSOLUTE path: `subprocess` resolves a bare argv[0] against # the CHILD's PATH, and this one holds no bash on purpose. result = subprocess.run( - [shutil.which("bash"), str(COMMAND), "--install"], + [BASH, bash_path(COMMAND), "--install"], capture_output=True, text=True, check=False, input="", env=command_env(home=tmp_path, setup_sh=None, env={**env, "OPENREPOSHAPE_BIN_DIR": str(bin_dir)})) @@ -630,11 +669,11 @@ def test_a_ref_the_doctor_would_check_out_is_validated(ref, tmp_path): any clone does. """ result = subprocess.run( - ["bash", "-s", "--", "--doctor", str(tmp_path)], + ["bash", "-s", "--", "--doctor", str(tmp_path)], executable=BASH, capture_output=True, text=True, check=False, input=COMMAND.read_text(encoding="utf-8"), cwd=str(tmp_path), env=command_env(home=tmp_path, setup_sh=None, - env={"OPENREPOSHAPE_REPO": str(REPO), + env={"OPENREPOSHAPE_REPO": bash_path(REPO), "OPENREPOSHAPE_REF": ref})) assert result.returncode == 2, result.stdout + result.stderr assert "OPENREPOSHAPE_REF" in result.stderr @@ -671,11 +710,11 @@ def test_a_checkout_without_the_doctor_refuses_by_name(tmp_path): subprocess.run(["git", "push", "-q", "origin", "main"], cwd=str(seed), capture_output=True, check=True) result = subprocess.run( - ["bash", "-s", "--", "--doctor", str(tmp_path)], + ["bash", "-s", "--", "--doctor", str(tmp_path)], executable=BASH, capture_output=True, text=True, check=False, input=COMMAND.read_text(encoding="utf-8"), cwd=str(tmp_path), env=command_env(home=tmp_path, setup_sh=None, - env={"OPENREPOSHAPE_REPO": str(bare), + env={"OPENREPOSHAPE_REPO": bash_path(bare), "TMPDIR": str(temporary)})) assert result.returncode == 2, result.stdout + result.stderr assert "has no shape-doctor.py" in result.stderr @@ -813,7 +852,7 @@ def fake_github(tmp_path, served_names, log: Path | None = None) -> dict: """ served = tmp_path / "served" served.mkdir(exist_ok=True) - (served / "setup.sh").write_text(STUB_SETUP_SH, encoding="utf-8") + write_script(served / "setup.sh", STUB_SETUP_SH) for name in served_names: (served / name).write_bytes((REPO / name).read_bytes()) @@ -831,20 +870,22 @@ def logging(program): return "" if log is None else \ f'printf \'{program} %s\\n\' "$*" >> \'{log}\'\n' - (fake / "gh").write_text( + write_script( + fake / "gh", "#!/bin/sh\n" + logging("gh") + 'case "$*" in\n' + routes + "esac\n" 'printf \'fake gh: unexpected call: %s\\n\' "$*" >&2\n' - "exit 1\n", encoding="utf-8") + "exit 1\n") (fake / "gh").chmod(0o755) - (fake / "curl").write_text( + write_script( + fake / "curl", "#!/bin/sh\n" + logging("curl") + "printf 'fake curl: no test here may reach the network\\n' >&2\n" - "exit 1\n", encoding="utf-8") + "exit 1\n") (fake / "curl").chmod(0o755) return {"PATH": f"{fake}{os.pathsep}{os.environ['PATH']}"} @@ -884,7 +925,12 @@ def test_the_fetched_setup_sh_lands_in_a_workdir_that_still_exists(offline_githu + result.stdout + result.stderr) ran_in, forwarded = match.group(1), match.group(2).split() assert "--preflight" in forwarded, forwarded - assert not Path(ran_in).exists(), ( + ran_dir = native_path(ran_in) + # The directory it was made in is still there, so the line after this + # one reads a path this interpreter can open, and cannot pass on one it + # cannot: `/tmp/...` read as a Windows path names no directory at all. + assert ran_dir.parent.is_dir(), ran_in + assert not ran_dir.exists(), ( f"{ran_in} outlived the command; the EXIT trap did not fire") @@ -915,8 +961,9 @@ def test_a_failed_fetch_replaces_nothing(tmp_path): encoding="utf-8") withheld = fake_github(tmp_path, []) result = subprocess.run( - ["bash", "-s", "--", "--install"], capture_output=True, text=True, - check=False, input=COMMAND.read_text(encoding="utf-8"), + ["bash", "-s", "--", "--install"], executable=BASH, + capture_output=True, text=True, check=False, + input=COMMAND.read_text(encoding="utf-8"), cwd=str(tmp_path), env=command_env(home=tmp_path, setup_sh=None, env={**withheld, @@ -963,10 +1010,9 @@ def test_all_or_none_still_holds_at_more_than_one_installable(tmp_path): source = COMMAND.read_text(encoding="utf-8") assert "\nINSTALLABLES=(openRepoShape)\n" in source, ( "the array this test lengthens has moved or been reshaped") - shim.write_text( - source.replace("\nINSTALLABLES=(openRepoShape)\n", - "\nINSTALLABLES=(openRepoShape witness)\n", 1), - encoding="utf-8") + write_script( + shim, source.replace("\nINSTALLABLES=(openRepoShape)\n", + "\nINSTALLABLES=(openRepoShape witness)\n", 1)) bin_dir = tmp_path / "bin" bin_dir.mkdir() @@ -974,8 +1020,8 @@ def test_all_or_none_still_holds_at_more_than_one_installable(tmp_path): encoding="utf-8") withheld = fake_github(tmp_path, ["openRepoShape"]) result = subprocess.run( - ["bash", str(shim), "--install"], capture_output=True, text=True, - check=False, input="", + [BASH, bash_path(shim), "--install"], capture_output=True, + text=True, check=False, input="", env=command_env(home=tmp_path, setup_sh=None, env={**withheld, "OPENREPOSHAPE_BIN_DIR": str(bin_dir)})) @@ -1007,8 +1053,9 @@ def test_install_from_stdin_fetches_itself_into_a_live_workdir(offline_github, """ bin_dir = tmp_path / "bin" result = subprocess.run( - ["bash", "-s", "--", "--install"], capture_output=True, text=True, - check=False, input=COMMAND.read_text(encoding="utf-8"), + ["bash", "-s", "--", "--install"], executable=BASH, + capture_output=True, text=True, check=False, + input=COMMAND.read_text(encoding="utf-8"), # cwd: `bash -s` leaves $BASH_SOURCE unset, so the command's $SELF is # the literal `bash`, and `[ -f "$SELF" ]` must be false for the # fetching branch to be the one under test. A directory holding @@ -1023,7 +1070,7 @@ def test_install_from_stdin_fetches_itself_into_a_live_workdir(offline_github, for name in INSTALLED: target = bin_dir / name assert target.is_file(), result.stdout + result.stderr + f" ({name})" - assert stat.S_IMODE(target.stat().st_mode) == 0o755, name + assert_a_command(target) assert target.read_bytes() == (REPO / name).read_bytes(), name assert f"{name}: installed at" in result.stdout @@ -1044,7 +1091,7 @@ def install_at(tmp_path, ref=None, command=COMMAND, opt_in=True): 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)} + env = {"OPENREPOSHAPE_BIN_DIR": bash_path(bin_dir)} if ref is not None: env["OPENREPOSHAPE_REF"] = ref if opt_in: @@ -1084,9 +1131,9 @@ def test_install_records_the_ref_beside_a_command_it_leaves_unchanged( 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}"] + f"openRepoShape: installed at {bash_path(installed)}", + f"openRepoShape: pinned to {PINNED}, recorded in {bash_path(record)}", + f"openRepoShape: 1 of 1 placed in {bash_path(installed.parent)}"] assert version_of(installed) == \ f"openRepoShape (opensoft/openRepoShape @ {PINNED})" assert version_of(installed, {"OPENREPOSHAPE_REF": OTHER}) == \ @@ -1118,13 +1165,14 @@ def test_installing_again_keeps_moves_or_drops_the_record(tmp_path): 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} " \ + assert f"openRepoShape: already pinned to {PINNED} in " \ + f"{bash_path(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 + f"{bash_path(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" @@ -1166,7 +1214,7 @@ def test_a_record_that_is_not_a_ref_is_refused_by_name(tmp_path, text, shown): 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}" \ + assert f"the ref recorded in {bash_path(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 @@ -1227,6 +1275,35 @@ def test_a_link_to_the_installed_command_reads_its_record(tmp_path, links): f"openRepoShape (opensoft/openRepoShape @ {PINNED})" +@pytest.mark.parametrize("msys", [None, "winsymlinks:nativestrict"], + ids=["ln-default", "ln-nativestrict"]) +def test_a_link_made_with_ln_s_reads_the_record_and_a_copy_follows_main( + tmp_path, msys): + """`ln -s` IN GIT BASH MAKES A COPY unless `$MSYS` says + `winsymlinks:nativestrict` (#585), which makes a Windows symlink. A copy + has no record beside it, so it follows main and `--version` says so; + a link, made anywhere else or with that setting, reads the record of + the command it leads to. `$MSYS` means nothing off Windows. A `$MSYS` + of the caller's own is dropped first, so the default is the default; + the setting, like every symlink test here, needs Windows' right to make + a symlink (Developer Mode, or an elevated shell).""" + _result, installed = install_at(tmp_path, PINNED) + link = tmp_path / "ors" + env = {name: value for name, value in os.environ.items() + if name != "MSYS"} + if msys: + env["MSYS"] = msys + made = subprocess.run([BASH, "-c", 'ln -s "$1" "$2"', "-", + bash_path(installed), bash_path(link)], + capture_output=True, text=True, check=False, + env=env) + assert made.returncode == 0, made.stderr + copied = os.name == "nt" and msys is None + assert link.is_symlink() is not copied + assert version_of(link) == "openRepoShape (opensoft/openRepoShape @ " \ + f"{'main' if copied else 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.""" @@ -1237,7 +1314,8 @@ def test_a_record_that_is_a_directory_is_refused_in_the_usual_form(tmp_path): 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 result.stderr.startswith( + f"\nREFUSED: {bash_path(record)} is not a file") assert "Is a directory" not in result.stderr assert installed.read_bytes() == COMMAND.read_bytes() @@ -1260,7 +1338,7 @@ def test_setup_sh_clones_the_standard_at_the_ref_in_force( 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") + write_script(stub, STUB_SETUP_SH) result = run_cmd("--preflight", command=installed, setup_sh=stub, env=None if variable is None else {"OPENREPOSHAPE_REF": variable}) @@ -1312,7 +1390,7 @@ def test_the_doctor_checks_its_clone_out_at_the_ref_in_force( _result, installed = install_at(tmp_path, recorded) temporary = tmp_path / "tmp" temporary.mkdir() - env = {"OPENREPOSHAPE_REPO": str(standard_at_three_refs(tmp_path)), + env = {"OPENREPOSHAPE_REPO": bash_path(standard_at_three_refs(tmp_path)), "TMPDIR": str(temporary)} if variable is not None: env["OPENREPOSHAPE_REF"] = variable