From be3d0fd03fc495d398d4a78b2311cec435290fba Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Sun, 25 Oct 2026 19:35:44 +1000 Subject: [PATCH 1/3] Activate ROS on Windows when conda runs in Git Bash / MSYS2 / Cygwin conda's posix activator runs only etc/conda/activate.d/*.sh, also on Windows, but ros_workspace installed only the .bat/.ps1 scripts there (bld_catkin.bat.in), so a conda env activated from Git Bash got no ROS environment (#182). Install activate.sh/deactivate.sh on Windows too and give them a Windows branch that does what activate.bat -> local_setup.bat does: run ament's _local_setup_util.py in "sh" mode to evaluate every package's .dsv hooks, keep values in Windows form for native programs, convert PATH entries with cygpath, and strip ';' (not ':') separators. The Unix branch is unchanged. Co-Authored-By: Claude Opus 5.5 --- vinca/templates/activate.sh.in | 94 +++++++++++++++++++++++++------ vinca/templates/bld_catkin.bat.in | 2 + vinca/templates/deactivate.sh.in | 11 ++++ 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/vinca/templates/activate.sh.in b/vinca/templates/activate.sh.in index 4a35f58..679fea1 100644 --- a/vinca/templates/activate.sh.in +++ b/vinca/templates/activate.sh.in @@ -4,23 +4,85 @@ # exit 0; # fi -# Not sure if this is necessary on UNIX? -# export QT_PLUGIN_PATH=$CONDA_PREFIX\plugins +case "$OSTYPE" in + msys*|cygwin*) + # Windows: conda runs this script (not activate.bat) when the environment + # is activated from Git Bash / MSYS2 / Cygwin. The ROS packages only ship + # .bat/.dsv environment hooks there (under %CONDA_PREFIX%\Library), so do + # what activate.bat -> local_setup.bat does: let ament's + # _local_setup_util.py evaluate every package's .dsv hooks, in "sh" mode, + # and apply the result. Values stay in Windows form (C:\...;C:\...) since + # native Windows programs (python, cmake, ros2) read them; only PATH is + # converted to the form the shell itself uses. + if [ -n "${SYS_PREFIX:-}" ]; then + # Don't do anything when we are in conda build (as activate.bat). + : + elif ! command -v cygpath > /dev/null 2>&1; then + echo "Not activating ROS: cygpath not found" >&2 + else + _ros_prefix_w="$(cygpath -w "$CONDA_PREFIX")" + _ros_python="$(cygpath -u "$_ros_prefix_w\\python.exe")" + _ros_util="$(cygpath -u "$_ros_prefix_w\\Library\\_local_setup_util.py")" -if [ "$CONDA_BUILD" = "1" -a "$target_platform" != "$build_platform" ]; then - # ignore sourcing - echo "Not activating ROS when cross-compiling"; -else - source $CONDA_PREFIX/setup.sh -fi + export QT_PLUGIN_PATH="$_ros_prefix_w\\Library\\plugins" + unset PYTHONHOME -case "$OSTYPE" in - darwin*) export ROS_OS_OVERRIDE="conda:osx";; - linux*) export ROS_OS_OVERRIDE="conda:linux";; -esac + if [ -f "$_ros_util" ]; then + while IFS= read -r _ros_line; do + case "$_ros_line" in + "export PATH=\""*";\$PATH\"") + # prepend: convert C:\...\bin to /c/.../bin and use ':' + _ros_value="${_ros_line#export PATH=\"}" + _ros_value="${_ros_value%;\$PATH\"}" + export PATH="$(cygpath -u "$_ros_value"):$PATH" + ;; + "export PATH="*) + # anything else touching PATH: skip rather than corrupt it + ;; + "export "*"#:}") + # strip a leading separator: ';' on Windows, not ':' + eval "${_ros_line%#:\}}#;}" + ;; + "export "*"%:}") + # strip a trailing separator: ';' on Windows, not ':' + eval "${_ros_line%\%:\}}%;}" + ;; + "export "*) + eval "$_ros_line" + ;; + esac + done < Date: Sun, 25 Oct 2026 15:09:13 +0100 Subject: [PATCH 2/3] Improve activation on git bash --- vinca/templates/activate.sh.in | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/vinca/templates/activate.sh.in b/vinca/templates/activate.sh.in index 679fea1..db2b1b4 100644 --- a/vinca/templates/activate.sh.in +++ b/vinca/templates/activate.sh.in @@ -20,6 +20,8 @@ case "$OSTYPE" in elif ! command -v cygpath > /dev/null 2>&1; then echo "Not activating ROS: cygpath not found" >&2 else + # Keep an absolute path: a .dsv assignment may remove cygpath from PATH. + _ros_cygpath="$(command -v cygpath)" _ros_prefix_w="$(cygpath -w "$CONDA_PREFIX")" _ros_python="$(cygpath -u "$_ros_prefix_w\\python.exe")" _ros_util="$(cygpath -u "$_ros_prefix_w\\Library\\_local_setup_util.py")" @@ -30,22 +32,27 @@ case "$OSTYPE" in if [ -f "$_ros_util" ]; then while IFS= read -r _ros_line; do case "$_ros_line" in - "export PATH=\""*";\$PATH\"") - # prepend: convert C:\...\bin to /c/.../bin and use ':' - _ros_value="${_ros_line#export PATH=\"}" - _ros_value="${_ros_value%;\$PATH\"}" - export PATH="$(cygpath -u "$_ros_value"):$PATH" - ;; - "export PATH="*) - # anything else touching PATH: skip rather than corrupt it - ;; "export "*"#:}") # strip a leading separator: ';' on Windows, not ':' - eval "${_ros_line%#:\}}#;}" + _ros_line="${_ros_line%#:\}}#;}" ;; "export "*"%:}") # strip a trailing separator: ';' on Windows, not ':' - eval "${_ros_line%\%:\}}%;}" + _ros_line="${_ros_line%\%:\}}%;}" + ;; + esac + case "$_ros_line" in + "export PATH="*) + # ament emits Windows lists even in sh mode. Evaluate against a + # Windows PATH in a subshell, then convert the complete result. + # This handles prepends, appends and assignments without mixing + # semicolons, drive-letter colons and Bash's ':' separators. + _ros_value="$( + PATH="$("$_ros_cygpath" -w -p "$PATH")" + eval "$_ros_line" + printf '%s' "$PATH" + )" + export PATH="$("$_ros_cygpath" -u -p "$_ros_value")" ;; "export "*) eval "$_ros_line" @@ -60,7 +67,7 @@ EOF export ROS_ETC_DIR="$_ros_prefix_w\\Library\\etc\\ros" export AMENT_PREFIX_PATH="$_ros_prefix_w\\Library" export AMENT_PYTHON_EXECUTABLE="$_ros_prefix_w\\python.exe" - unset _ros_prefix_w _ros_python _ros_util _ros_line _ros_value + unset _ros_prefix_w _ros_python _ros_util _ros_line _ros_value _ros_cygpath fi ;; *) From aefcab9da35211a7bf1f91d2d589210c15d7e352 Mon Sep 17 00:00:00 2001 From: Silvio Traversaro Date: Mon, 26 Oct 2026 08:57:12 +0100 Subject: [PATCH 3/3] Test ROS activation with conda and Git Bash on Windows CI --- .github/workflows/ci.yml | 13 ++ tests/windows/README.md | 24 ++++ tests/windows/environment.yml | 11 ++ vinca/test_windows_activation.py | 224 +++++++++++++++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 tests/windows/README.md create mode 100644 tests/windows/environment.yml create mode 100644 vinca/test_windows_activation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 429e11c..3d9d6b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,16 @@ jobs: - name: Run tests run: pixi run test + + - name: Install Windows ROS activation test environment + if: runner.os == 'Windows' + run: >- + pixi exec --spec conda -- conda env create --yes + --prefix "${{ runner.temp }}/vinca-ros-activation" + --file tests/windows/environment.yml + + - name: Test ROS activation with conda and Git Bash + if: runner.os == 'Windows' + env: + VINCA_WINDOWS_ROS_PREFIX: ${{ runner.temp }}/vinca-ros-activation + run: pixi run pytest -v vinca/test_windows_activation.py diff --git a/tests/windows/README.md b/tests/windows/README.md new file mode 100644 index 0000000..80b2dc4 --- /dev/null +++ b/tests/windows/README.md @@ -0,0 +1,24 @@ +The Windows CI job installs `environment.yml` into a disposable conda environment +and runs `vinca/test_windows_activation.py` using Git for Windows Bash. The test +renders and installs the PR's hooks, then activates the environment through real +conda. It checks the conda-build guard, ROS variables, native Python's ament index +lookup, repeated activation, and deactivation. It restores the installed hooks +afterward. An additional package with real `.dsv` hooks exercises PATH prepends, +appends and assignments, PYTHONPATH prepends, a CMAKE_PREFIX_PATH append, and paths +containing spaces. The assignment case replaces the inherited PATH with an +explicit Windows path list and checks its exact contents and order. + +To reproduce in PowerShell from the repository root: + +```powershell +pixi exec --spec conda -- conda env create --yes --prefix "$PWD/.pixi/ros-activation" --file tests/windows/environment.yml +$env:VINCA_WINDOWS_ROS_PREFIX = "$PWD/.pixi/ros-activation" +pixi run pytest -v vinca/test_windows_activation.py +``` + +The integration test skips unless `VINCA_WINDOWS_ROS_PREFIX` is set, and skips on +non-Windows platforms. Once a prefix is supplied, missing dependencies or Git Bash +fail the test rather than skipping it. Use a disposable environment: the test +temporarily replaces its ros_workspace activation hooks. + +This test does not run a full rclcpp CMake build. diff --git a/tests/windows/environment.yml b/tests/windows/environment.yml new file mode 100644 index 0000000..4d1ca09 --- /dev/null +++ b/tests/windows/environment.yml @@ -0,0 +1,11 @@ +name: vinca-ros-activation +channels: + - robostack-jazzy + - conda-forge + - nodefaults +dependencies: + - python=3.12 + - conda + - ros-jazzy-ros-workspace + - ros-jazzy-ros-environment + - ros-jazzy-ament-index-python diff --git a/vinca/test_windows_activation.py b/vinca/test_windows_activation.py new file mode 100644 index 0000000..b1a29dc --- /dev/null +++ b/vinca/test_windows_activation.py @@ -0,0 +1,224 @@ +"""Real Git Bash + conda regression test (see tests/windows/README.md).""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from vinca.template import ( + generate_activation_scripts_for_recipe, + generate_build_script_for_recipe, +) + +pytestmark = pytest.mark.skipif(os.name != "nt", reason="requires Windows Git Bash") + +ROS_VARIABLES = ( + "ROS_DISTRO", + "ROS_VERSION", + "ROS_PYTHON_VERSION", + "ROS_OS_OVERRIDE", + "ROS_ETC_DIR", + "AMENT_PREFIX_PATH", + "AMENT_PYTHON_EXECUTABLE", + "PYTHONPATH", + "QT_PLUGIN_PATH", + "CMAKE_PREFIX_PATH", +) + + +@pytest.fixture(scope="module") +def activation_recipe(tmp_path_factory): + if not os.environ.get("VINCA_WINDOWS_ROS_PREFIX"): + pytest.skip("set VINCA_WINDOWS_ROS_PREFIX to a disposable Windows ROS env") + recipe = tmp_path_factory.mktemp("activation_recipe") + generate_activation_scripts_for_recipe(recipe) + generate_build_script_for_recipe( + "bld_catkin.bat", recipe / "bld_catkin.bat", "ros-jazzy" + ) + return recipe + + +@pytest.mark.parametrize( + "path_operation", ["prepend-non-duplicate", "append-non-duplicate", "set"] +) +def test_conda_git_bash_activation(tmp_path, path_operation, activation_recipe): + prefix = Path(os.environ["VINCA_WINDOWS_ROS_PREFIX"]).resolve() + bash = Path(os.environ["ProgramFiles"]) / "Git/bin/bash.exe" + assert bash.is_file(), f"Git for Windows Bash is required: {bash}" + conda = prefix / "Scripts/conda.exe" + assert conda.is_file(), f"Install tests/windows/environment.yml in {prefix}" + assert (prefix / "Library/_local_setup_util.py").is_file() + + recipe = activation_recipe + build = recipe / "bld_catkin.bat" + # Run the actual installation block, without rebuilding ros_workspace. + install_block = build.read_text().split( + 'if "%PKG_NAME%" == "ros-jazzy-ros-workspace" (', 1 + )[1] + install = recipe / "install.bat" + install.write_text( + '@echo off\nif "%PKG_NAME%" == "ros-jazzy-ros-workspace" (' + install_block + ) + + env = { + key: value + for key, value in os.environ.items() + if not key.upper().startswith(("CONDA", "_CE_", "ROS_", "AMENT_")) + and key.upper() + not in { + "PYTHONHOME", + "PYTHONPATH", + "QT_PLUGIN_PATH", + "SYS_PREFIX", + "CMAKE_PREFIX_PATH", + } + } + env.update( + PREFIX=str(prefix), + RECIPE_DIR=str(recipe), + PKG_NAME="ros-jazzy-ros-workspace", + CONDA_AUTO_ACTIVATE_BASE="false", + CONDA_NO_PLUGINS="true", + ) + + # Restore any pre-existing hooks, including when an assertion fails. + hooks = [ + prefix / f"etc/conda/{action}.d/ros-jazzy-ros-workspace_{action}.{ext}" + for action in ("activate", "deactivate") + for ext in ("bat", "ps1", "sh") + ] + originals = {path: path.read_bytes() if path.exists() else None for path in hooks} + # Exercise both prepend/append separators and paths with spaces using the + # real ament helper, rather than a Python executable that mocks its output. + package_dir = prefix / "Library/share/vinca_activation_probe" + marker = ( + prefix + / "Library/share/ament_index/resource_index/packages/vinca_activation_probe" + ) + assert not package_dir.exists() and not marker.exists() + extra_path = tmp_path / "hook paths with spaces" + extra_path.mkdir() + (extra_path / "vinca_probe.py").write_text("VALUE = 42\n") + path_value = str(extra_path) + if path_operation == "set": + # Keep Git's utilities available for conda itself, but deliberately + # replace the inherited PATH with a Windows list containing spaces. + path_value += ";" + str(bash.parent.parent / "usr/bin") + try: + package_dir.mkdir() + marker.touch() + (package_dir / "package.dsv").write_text( + f"prepend-non-duplicate;PYTHONPATH;{extra_path}\n" + f"{path_operation};PATH;{path_value}\n" + f"append-non-duplicate;CMAKE_PREFIX_PATH;{extra_path}\n" + ) + result = subprocess.run( + ["cmd.exe", "/d", "/c", str(install)], + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert all(path.is_file() for path in hooks) + + probe = tmp_path / "probe.py" + probe.write_text( + "import json, os, sys\n" + "from pathlib import Path\n" + f"keys = {(*ROS_VARIABLES, 'PATH')!r}\n" + "data = {key: os.environ[key] for key in keys if key in os.environ}\n" + "if sys.argv[2] == 'active':\n" + " from vinca_probe import VALUE\n" + " assert VALUE == 42\n" + " from ament_index_python.packages import get_package_prefix\n" + " data['workspace_prefix'] = get_package_prefix('ros_workspace')\n" + "Path(sys.argv[1]).write_text(json.dumps(data))\n" + ) + driver = tmp_path / "test.sh" + driver.write_text( + r"""set -e +conda_exe=$(cygpath -u "$1") +prefix=$(cygpath -u "$2") +probe=$(cygpath -u "$3") +out=$(cygpath -u "$4") +eval "$("$conda_exe" shell.bash hook)" +# conda-build must not run ROS hooks. +export CONDA_PREFIX="$prefix" SYS_PREFIX=building +source "$prefix/etc/conda/activate.d/ros-jazzy-ros-workspace_activate.sh" +"$prefix/python.exe" "$probe" "$out/build.json" inactive +unset SYS_PREFIX CONDA_PREFIX +conda activate "$prefix" +"$prefix/python.exe" "$probe" "$out/first.json" active +conda activate "$prefix" +"$prefix/python.exe" "$probe" "$out/second.json" active +conda deactivate +"$prefix/python.exe" "$probe" "$out/deactivated.json" inactive +""", + newline="\n", + ) + result = subprocess.run( + [ + str(bash), + "--noprofile", + "--norc", + str(driver), + str(conda), + str(prefix), + str(probe), + str(tmp_path), + ], + env=env, + capture_output=True, + text=True, + timeout=300, + ) + assert result.returncode == 0, result.stdout + result.stderr + first = json.loads((tmp_path / "first.json").read_text()) + second = json.loads((tmp_path / "second.json").read_text()) + assert first["ROS_DISTRO"] == "jazzy" + assert first["ROS_VERSION"] == "2" + assert first["ROS_PYTHON_VERSION"] == "3" + assert first["ROS_OS_OVERRIDE"] == "conda:win64" + for variable, expected in { + "ROS_ETC_DIR": prefix / "Library/etc/ros", + "AMENT_PREFIX_PATH": prefix / "Library", + "AMENT_PYTHON_EXECUTABLE": prefix / "python.exe", + "QT_PLUGIN_PATH": prefix / "Library/plugins", + "workspace_prefix": prefix / "Library", + "CMAKE_PREFIX_PATH": extra_path, + }.items(): + assert Path(first[variable]) == expected, (variable, first[variable]) + python_paths = first["PYTHONPATH"].split(";") + assert python_paths and all(python_paths) + assert len(python_paths) == len(set(python_paths)) + assert any( + Path(p) == prefix / "Library/lib/site-packages" for p in python_paths + ) + assert Path(python_paths[0]) == extra_path + assert sum(Path(p) == extra_path for p in first["PATH"].split(";")) == 1 + native_paths = [Path(p) for p in first["PATH"].split(";")] + if path_operation == "append-non-duplicate": + assert native_paths[-1] == extra_path + else: + assert native_paths[0] == extra_path + if path_operation == "set": + assert native_paths == [extra_path, bash.parent.parent / "usr/bin"] + assert {key: first[key] for key in ROS_VARIABLES} == { + key: second[key] for key in ROS_VARIABLES + } + assert first["PATH"] == second["PATH"] + for state in ("build", "deactivated"): + values = json.loads((tmp_path / f"{state}.json").read_text()) + assert not {key: values[key] for key in ROS_VARIABLES if key in values} + finally: + marker.unlink(missing_ok=True) + (package_dir / "package.dsv").unlink(missing_ok=True) + if package_dir.exists(): + package_dir.rmdir() + for path, content in originals.items(): + if content is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(content)