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.
@@ -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