"""pm.shell(): the one place Hermes resolves the shell it runs commands with. Owned by pm because the shell is a bundled tool on Windows (Git for Windows carries bash.exe), and the store is the authority on whether it exists. Callers that need bash (the terminal backend, `_find_bash`) call this instead of hunting fixed locations. Resolution order: 1. Windows: the git Package's staged bash (via facts.json) — the store structurally guarantees it in a bundle; no hunt. 2. Provisioned PATH: shutil.which("bash") — the store dirs are on the process PATH after pm.activate() ran. 3. Conventional Git for Windows under Program Files, even with an empty PATH. 4. POSIX fallback table for non-bundle / daemon-launch PATH edge cases (/usr/bin/bash, /bin/bash, $SHELL, /bin/sh). A systemd/cron-launched gateway may have a minimal PATH and macOS /bin/bash is not on PATH by default, so `which` alone is not enough there. """ from __future__ import annotations import os import shutil from pm import paths from pm.lock import Facts def _staged_bash() -> str | None: """The git Package's bash.exe under the store (Windows bundles only).""" import platform if platform.system() != "Windows": return None facts_path = paths.facts_path() if not facts_path.is_file(): return None try: facts = Facts(facts_path) fact = facts.get("git") if fact is None or "entry" not in fact: return None entry = paths.store_root() / fact["entry"] for candidate in ( entry / "usr" / "bin" / "bash.exe", entry / "bin" / "bash.exe", ): if candidate.is_file(): return str(candidate) except Exception: return None return None def bash() -> str | None: """Resolve the bash binary to use, or None if none is available.""" staged = _staged_bash() if staged: return staged on_path = shutil.which("bash") # An MSIX-packaged bash (WindowsApps alias) only spawns inside its # package's context — from an arbitrary process (dev checkout, test # child, emulated-python CreateProcess) it fails with WinError 5. # Prefer a conventional install when one exists. if on_path and "windowsapps" not in on_path.lower(): return on_path if os.name == "nt": programfiles = os.environ.get("ProgramFiles", r"C:\Program Files") for rel in (("Git", "bin", "bash.exe"), ("Git", "usr", "bin", "bash.exe")): cand = os.path.join(programfiles, *rel) if os.path.isfile(cand): return cand return on_path # POSIX fallbacks for minimal-PATH daemon launches / macOS /bin/bash. for candidate in ( "/usr/bin/bash", "/bin/bash", os.environ.get("SHELL"), "/bin/sh", ): if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK): return candidate return None