diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f405e5c..648099c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -263,6 +263,14 @@ per-file diff commands. `MadsLorentzen/ai-job-search/1.0 (freehire-search)`. The host-check test now lists `github.com` and `gist.github.com` postings among the hosts that must stay unverified. +- **The `/reset` replay tests find a Bash that actually runs the script on Windows** (#671, + `tests/_bash.py`, `tests/test_bash.py`, `tests/test_reset_command.py`, `tests/test_reset_documents.py`) - + the tests replayed the documented `/reset` commands with whichever `bash` came first on PATH. On + Windows that is often the WSL launcher, which expands variables before WSL's bash sees them, so the + replayed script deleted nothing and two tests failed. Each candidate is now probed with + `x=ok; test "$x" = ok`, Git for Windows' own Bash is tried when the launcher fails the probe, and the + replay checks skip only when no usable shell is found. + ### Security - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a diff --git a/tests/_bash.py b/tests/_bash.py new file mode 100644 index 00000000..1bda1d96 --- /dev/null +++ b/tests/_bash.py @@ -0,0 +1,31 @@ +"""Find a usable Bash for tests that replay the documented reset commands.""" +import os +from pathlib import Path +import shutil +import subprocess + +WINDOWS = os.name == "nt" + + +def find_bash(): + candidates = [shutil.which("bash")] + if WINDOWS: + # A Windows PATH may find the WSL launcher even without a usable WSL + # installation. Git for Windows also ships Bash beside its cmd/ entry + # points, but does not normally add that bin/ directory to PATH. + git = shutil.which("git") + if git: + candidates.append(str(Path(git).resolve().parent.parent / "bin" / "bash.exe")) + for candidate in dict.fromkeys(candidates): + if not candidate: + continue + try: + result = subprocess.run( + [candidate, "-c", 'x=ok; test "$x" = ok'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, + ) + except (OSError, subprocess.SubprocessError): + continue + if result.returncode == 0: + return candidate + return None diff --git a/tests/test_bash.py b/tests/test_bash.py new file mode 100644 index 00000000..ed851fdf --- /dev/null +++ b/tests/test_bash.py @@ -0,0 +1,70 @@ +"""A Bash launcher on PATH is not necessarily a usable shell (Windows WSL).""" +from pathlib import Path +import subprocess +import unittest +from unittest import mock + +try: + from tests import _bash +except ImportError: + import _bash + + +class BashDiscoveryTests(unittest.TestCase): + def test_working_path_shell_is_used(self): + with mock.patch.object(_bash, "WINDOWS", False), \ + mock.patch.object(_bash.shutil, "which", return_value="/bin/bash"), \ + mock.patch.object(_bash.subprocess, "run", return_value=mock.Mock(returncode=0)) as run: + self.assertEqual(_bash.find_bash(), "/bin/bash") + self.assertEqual(run.call_args.args[0], ["/bin/bash", "-c", 'x=ok; test "$x" = ok']) + + def test_launcher_that_expands_variables_falls_back_to_git_bash(self): + git = Path(__file__).resolve().parent / "Git" / "cmd" / "git.exe" + fallback = str(git.parent.parent / "bin" / "bash.exe") + + def probe(args, **kwargs): + script = args[2] + if args[0] == "wsl-bash": + # The launcher can exit successfully, but expands $x before + # Bash assigns it, so the reset command cannot work. + script = script.replace("$x", "") + return subprocess.CompletedProcess(args, 0 if script in ( + "exit 0", 'x=ok; test "$x" = ok', + ) else 1) + + with mock.patch.object(_bash, "WINDOWS", True), \ + mock.patch.object(_bash.shutil, "which", side_effect=["wsl-bash", str(git)]), \ + mock.patch.object(_bash.subprocess, "run", side_effect=probe): + self.assertEqual(_bash.find_bash(), fallback) + + def test_missing_shell_needs_no_probe(self): + with mock.patch.object(_bash, "WINDOWS", False), \ + mock.patch.object(_bash.shutil, "which", return_value=None), \ + mock.patch.object(_bash.subprocess, "run") as run: + self.assertIsNone(_bash.find_bash()) + run.assert_not_called() + + def test_unusable_path_shell_falls_back_to_git_bash_on_windows(self): + git = Path(__file__).resolve().parent / "Git" / "cmd" / "git.exe" + fallback = str(git.parent.parent / "bin" / "bash.exe") + with mock.patch.object(_bash, "WINDOWS", True), \ + mock.patch.object(_bash.shutil, "which", side_effect=["wsl-bash", str(git)]), \ + mock.patch.object(_bash.subprocess, "run", side_effect=[ + mock.Mock(returncode=1), mock.Mock(returncode=0), + ]) as run: + self.assertEqual(_bash.find_bash(), fallback) + self.assertEqual([call.args[0][0] for call in run.call_args_list], ["wsl-bash", fallback]) + + def test_failed_or_hanging_shell_is_unavailable(self): + for error in (OSError("cannot start"), subprocess.TimeoutExpired("bash", 5)): + with self.subTest(error=type(error).__name__), \ + mock.patch.object(_bash, "WINDOWS", False), \ + mock.patch.object(_bash.shutil, "which", return_value="bash"), \ + mock.patch.object(_bash.subprocess, "run", side_effect=error): + self.assertIsNone(_bash.find_bash()) + + def test_nonzero_exit_without_a_fallback_is_unavailable(self): + with mock.patch.object(_bash, "WINDOWS", False), \ + mock.patch.object(_bash.shutil, "which", return_value="bash"), \ + mock.patch.object(_bash.subprocess, "run", return_value=mock.Mock(returncode=1)): + self.assertIsNone(_bash.find_bash()) diff --git a/tests/test_reset_command.py b/tests/test_reset_command.py index 58a5a7d1..aa1326eb 100644 --- a/tests/test_reset_command.py +++ b/tests/test_reset_command.py @@ -28,21 +28,22 @@ tree, the profile files from /setup Step 3's own headings - so a new drop folder or a new /setup target fails this test until /reset covers it. """ import re -import shutil import subprocess import unittest from pathlib import Path from tempfile import TemporaryDirectory try: + from tests._bash import find_bash from tests._upstream import is_upstream_checkout except ImportError: # `python -m unittest discover -s tests` imports test modules top-level + from _bash import find_bash from _upstream import is_upstream_checkout REPO = Path(__file__).resolve().parent.parent RESET = REPO / ".claude" / "commands" / "reset.md" SETUP = REPO / ".claude" / "commands" / "setup.md" -BASH = shutil.which("bash") +BASH = find_bash() def tracked_document_subfolders(): diff --git a/tests/test_reset_documents.py b/tests/test_reset_documents.py index 4e26cf31..4373d5f8 100644 --- a/tests/test_reset_documents.py +++ b/tests/test_reset_documents.py @@ -8,9 +8,14 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory +try: + from tests._bash import find_bash +except ImportError: # unittest discover -s tests imports modules top-level + from _bash import find_bash + COMMAND = Path(__file__).resolve().parent.parent / ".claude/commands/reset.md" FOLDERS = ("cv", "linkedin", "diplomas", "references", "projects", "postings", "applications") -BASH = shutil.which("bash") +BASH = find_bash() def symlinks_available():