diff --git a/scripts/install_ruby.sh b/scripts/install_ruby.sh index 28570d4..819c631 100755 --- a/scripts/install_ruby.sh +++ b/scripts/install_ruby.sh @@ -5,6 +5,9 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$DIR/lib/common.sh" ACTION="${1:-reconcile}" +# Remember whether the caller pinned a version — uninstall treats an explicit +# RUBY_VERSION as "remove only this cycle" (like NODE_VERSION/UV_PYTHON_SPEC) +RUBY_VERSION_EXPLICIT="${RUBY_VERSION:+1}" # Target Ruby version (default: latest stable) RUBY_VERSION="${RUBY_VERSION:-3.3.6}" @@ -79,8 +82,10 @@ install_ruby() { fi # Check version before install - local before="$(get_specific_ruby_version "$RUBY_VERSION")" - local version_cycle="$(get_version_cycle "$RUBY_VERSION")" + local before + before="$(get_specific_ruby_version "$RUBY_VERSION")" + local version_cycle + version_cycle="$(get_version_cycle "$RUBY_VERSION")" local display_name="ruby" [ -n "$version_cycle" ] && display_name="ruby@${version_cycle}" @@ -99,7 +104,8 @@ install_ruby() { fi # Only set global if no global version is set yet - local current_global="$(rbenv global 2>/dev/null || true)" + local current_global + current_global="$(rbenv global 2>/dev/null || true)" if [ -z "$current_global" ] || [ "$current_global" = "system" ]; then rbenv global "$RUBY_VERSION" || true fi @@ -113,7 +119,8 @@ install_ruby() { rbenv rehash || true # Report version for this specific install - local after="$(get_specific_ruby_version "$RUBY_VERSION")" + local after + after="$(get_specific_ruby_version "$RUBY_VERSION")" local path="$HOME/.rbenv/versions/$RUBY_VERSION/bin/ruby" printf "[%s] before: %s\n" "$display_name" "${before:-}" printf "[%s] after: %s\n" "$display_name" "${after:-}" @@ -133,7 +140,42 @@ update_ruby() { } uninstall_ruby() { - # Remove rbenv-managed Ruby + if [ -n "$RUBY_VERSION_EXPLICIT" ]; then + # Multi-version: only remove the requested cycle, keep rbenv and other versions + if ! have rbenv && [ -x "$HOME/.rbenv/bin/rbenv" ]; then + export PATH="$HOME/.rbenv/bin:$PATH" + fi + if ! have rbenv; then + echo "[ruby] rbenv not found; nothing to remove for cycle $RUBY_VERSION" >&2 + return 0 + fi + + local cycle cycle_re resolved + cycle="$(get_version_cycle "$RUBY_VERSION")" + cycle_re="^${cycle//./\\.}(\\.|$)" + resolved="$(rbenv versions --bare 2>/dev/null | grep -E "$cycle_re" || true)" + if [ -z "$resolved" ]; then + echo "[ruby] Ruby $RUBY_VERSION not found in rbenv" >&2 + return 0 + fi + + local current_global other_ver v + current_global="$(rbenv global 2>/dev/null || true)" + for v in $resolved; do + if [ "$v" = "$current_global" ]; then + other_ver="$(rbenv versions --bare 2>/dev/null | grep -vE "$cycle_re" | tail -1 || true)" + if [ -n "$other_ver" ]; then + echo "[ruby] Warning: ruby $v is the current global, switching to $other_ver first" >&2 + rbenv global "$other_ver" || true + fi + fi + rbenv uninstall -f "$v" || true + echo "[ruby] Removed ruby $v (cycle $cycle)" >&2 + done + return 0 + fi + + # Full uninstall: remove rbenv-managed Ruby entirely if [ -d "$HOME/.rbenv" ]; then rm -rf "$HOME/.rbenv" fi diff --git a/tests/test_ruby_uninstall.py b/tests/test_ruby_uninstall.py new file mode 100644 index 0000000..0b0c0d6 --- /dev/null +++ b/tests/test_ruby_uninstall.py @@ -0,0 +1,136 @@ +"""Tests for per-cycle ruby uninstall (RUBY_VERSION=3.3 install_ruby.sh uninstall). + +node (NODE_VERSION) and python (UV_PYTHON_SPEC) support removing a single +version cycle; ruby's uninstall previously always nuked all of ~/.rbenv. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +skip_on_windows = pytest.mark.skipif( + sys.platform == "win32", reason="Shell script tests require POSIX shell" +) + +SCRIPT = Path(__file__).parent.parent / "scripts" / "install_ruby.sh" + +RBENV_STUB = """#!/usr/bin/env bash +echo "rbenv $*" >> "$RBENV_STUB_LOG" +case "$1" in + versions) + printf '%s\\n' $RBENV_STUB_VERSIONS + ;; + global) + if [ $# -eq 1 ]; then echo "$RBENV_STUB_GLOBAL"; fi + ;; +esac +exit 0 +""" + + +@skip_on_windows +class TestRubyPerCycleUninstall: + def _run(self, tmpdir: str, env_extra: dict, versions: str, global_ver: str, + args: list[str]) -> tuple[subprocess.CompletedProcess, str, Path]: + stub_dir = Path(tmpdir) / "stubs" + stub_dir.mkdir(exist_ok=True) + log = Path(tmpdir) / "rbenv.log" + log.touch() + for name, body in ( + ("rbenv", RBENV_STUB), + # keep the full-uninstall branch harmless if ever reached + ("sudo", "#!/usr/bin/env bash\nexit 0\n"), + ("dpkg", "#!/usr/bin/env bash\nexit 1\n"), + ("apt-get", "#!/usr/bin/env bash\nexit 0\n"), + ): + stub = stub_dir / name + stub.write_text(body) + stub.chmod(0o755) + + home = Path(tmpdir) / "home" + (home / ".rbenv" / "versions").mkdir(parents=True, exist_ok=True) + + env = { + **os.environ, + "HOME": str(home), + "PATH": f"{stub_dir}:{os.environ['PATH']}", + "RBENV_STUB_LOG": str(log), + "RBENV_STUB_VERSIONS": versions, + "RBENV_STUB_GLOBAL": global_ver, + **env_extra, + } + env.pop("RUBY_VERSION", None) + env.update({k: v for k, v in env_extra.items()}) + result = subprocess.run( + ["bash", str(SCRIPT), *args], + capture_output=True, text=True, timeout=30, env=env, + ) + return result, log.read_text(), home + + def test_removes_only_requested_cycle(self): + with tempfile.TemporaryDirectory() as tmpdir: + result, log, home = self._run( + tmpdir, {"RUBY_VERSION": "3.3"}, + versions="3.3.6 4.0.1 4.0.5", global_ver="4.0.5", + args=["uninstall"], + ) + assert result.returncode == 0, result.stderr + assert "rbenv uninstall -f 3.3.6" in log + assert "uninstall -f 4.0.1" not in log + assert "uninstall -f 4.0.5" not in log + # rbenv itself must survive a per-cycle removal + assert (home / ".rbenv").exists() + + def test_full_version_spec_removes_its_cycle(self): + with tempfile.TemporaryDirectory() as tmpdir: + result, log, _ = self._run( + tmpdir, {"RUBY_VERSION": "3.3.6"}, + versions="3.3.6 4.0.5", global_ver="4.0.5", + args=["uninstall"], + ) + assert result.returncode == 0, result.stderr + assert "rbenv uninstall -f 3.3.6" in log + + def test_switches_global_before_removing_it(self): + with tempfile.TemporaryDirectory() as tmpdir: + result, log, _ = self._run( + tmpdir, {"RUBY_VERSION": "3.3"}, + versions="3.3.6 4.0.5", global_ver="3.3.6", + args=["uninstall"], + ) + assert result.returncode == 0, result.stderr + lines = [line for line in log.splitlines() if line] + assert "rbenv global 4.0.5" in lines + assert lines.index("rbenv global 4.0.5") < lines.index( + "rbenv uninstall -f 3.3.6" + ) + + def test_missing_cycle_is_a_noop(self): + with tempfile.TemporaryDirectory() as tmpdir: + result, log, home = self._run( + tmpdir, {"RUBY_VERSION": "3.1"}, + versions="3.3.6 4.0.5", global_ver="4.0.5", + args=["uninstall"], + ) + assert result.returncode == 0, result.stderr + assert "uninstall -f" not in log + assert "not found" in result.stderr + result.stdout + assert (home / ".rbenv").exists() + + def test_full_uninstall_without_ruby_version_still_removes_rbenv(self): + with tempfile.TemporaryDirectory() as tmpdir: + result, _, home = self._run( + tmpdir, {}, + versions="3.3.6 4.0.5", global_ver="4.0.5", + args=["uninstall"], + ) + assert result.returncode == 0, result.stderr + assert not (home / ".rbenv").exists(), ( + "full uninstall must keep removing ~/.rbenv entirely" + )