The fallback required a prior PATH hit, so a normal Git for Windows installation could not satisfy an empty-PATH terminal. Check the existing conventional locations independently. Keep the staged tool and usable PATH precedence unchanged. The native test calls the actual local resolver and executes the selected shell with an empty PATH and an unusable WindowsApps alias. The focused shell gate passed. Other hosts retain their existing path.
86 lines
2.9 KiB
Python
86 lines
2.9 KiB
Python
"""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
|