From 2270d3511df878e73996c30d7bf3bad799a0c048 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:04:18 -0700 Subject: [PATCH] fix(update): expose PM's git only for git checkouts, and record the installer's copy A git-less Windows ZIP install has no .git and never runs git, yet _prepare_git_command called expose_pm_git() before it checked use_zip_update: offline or with no pin, pm.ensure raised and aborted an update that never needed git. expose_pm_git now takes the project root and does nothing unless it is a git checkout (both callers). When install.ps1 runs as one process, the git it staged into PM's store is on the inherited PATH, so expose_pm_git returned early and PM's facts never recorded it; plain hermes processes (plugin git installs, doctor, version info) had no git until the first `hermes update`. A git found under PM's store is now treated as that unrecorded copy and ensured, so the source completion writes the git fact at install time. Co-authored-by: JoaoMarcos44 --- hermes_cli/_subprocess_compat.py | 27 +++++++++++--------- hermes_cli/source_completion.py | 2 +- hermes_cli/update_cmd.py | 2 +- tests/hermes_cli/test_update_pm_git.py | 34 +++++++++++++++++++------- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/hermes_cli/_subprocess_compat.py b/hermes_cli/_subprocess_compat.py index 3fe7aaab89..d550c905b4 100644 --- a/hermes_cli/_subprocess_compat.py +++ b/hermes_cli/_subprocess_compat.py @@ -13,6 +13,7 @@ import re import shutil import subprocess import sys +from pathlib import Path from typing import Mapping, NoReturn, Sequence __all__ = [ @@ -372,22 +373,26 @@ def selected_git_env(base: Mapping[str, str] | None = None) -> dict[str, str]: return env -def expose_pm_git() -> None: - """Put PM's git on this process's PATH when Windows has no git of its own. +def expose_pm_git(project_root: Path) -> None: + """Put PM's git on PATH, and in PM's facts, for a Windows git checkout. - On a machine without git, install.ps1 stages the pinned Git for Windows for - its own process only; PM's facts never record it. Every later bare ``git`` - (``hermes update``, the source-completion stamp) then died with - ``[WinError 2]``. Callers are explicit user actions (like - ``ensure_tools_for_sync``), so acquire PM's git outright; children inherit - the PATH. A machine with a working git is untouched. Raises what - ``pm.ensure`` raises. + install.ps1 stages the pinned Git for Windows into PM's store for its own + process only, and PM's facts never record it, so every later bare ``git`` + (``hermes update``, the source-completion stamp, plugin installs, doctor) + died with ``[WinError 2]``. A git found under PM's store is that unrecorded + copy inherited from the installer, so it is recorded too. Callers are + explicit user actions (like ``ensure_tools_for_sync``), so acquire PM's git + outright; children inherit the PATH. The machine's own git, and a git-less + ZIP install that never runs git, are untouched. Raises what ``pm.ensure`` + raises. """ - if sys.platform != "win32": + if sys.platform != "win32" or not (Path(project_root) / ".git").exists(): return from hermes_platform.resolver import locate_command + from pm.paths import store_root - if locate_command("git").found: + found = locate_command("git").command + if found and not Path(found[0]).resolve().is_relative_to(store_root()): return from pm import ensure diff --git a/hermes_cli/source_completion.py b/hermes_cli/source_completion.py index 58ec5d296a..96b7a79dab 100644 --- a/hermes_cli/source_completion.py +++ b/hermes_cli/source_completion.py @@ -52,7 +52,7 @@ def complete_source_checkout( # The builds, the release-history refresh and the install stamp all run # git; a fresh Windows machine has only PM's. - expose_pm_git() + expose_pm_git(root) except Exception as exc: # noqa: BLE001 — git-less steps below still complete print(f"⚠ Could not provide git for the source completion: {exc}", file=sys.stderr) publish_launchers(root) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 74d1598718..88d424722c 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -1106,7 +1106,7 @@ def _prepare_git_command() -> tuple[bool, list, bool]: from hermes_cli._subprocess_compat import expose_pm_git - expose_pm_git() + expose_pm_git(_m().PROJECT_ROOT) git_cmd = _base_git_cmd() if sys.platform == "win32" and git_dir.exists(): _git_run(git_cmd, ["config", "windows.appendAtomically", "false"]) diff --git a/tests/hermes_cli/test_update_pm_git.py b/tests/hermes_cli/test_update_pm_git.py index a0f974f96e..37a364c28c 100644 --- a/tests/hermes_cli/test_update_pm_git.py +++ b/tests/hermes_cli/test_update_pm_git.py @@ -1,7 +1,8 @@ -"""Windows machines whose only git is the one PM provides (install.ps1 fresh installs).""" +"""Windows installs whose git is PM's: install.ps1 stages it, PM's facts must record it.""" import os import pm +import pm.paths import pytest from hermes_cli import _subprocess_compat as compat @@ -9,12 +10,21 @@ from pm.package import Runner @pytest.fixture -def windows(monkeypatch): +def windows(monkeypatch, tmp_path): + store = tmp_path / "tools" + staged_git = store / "git-2.53.0+3-win32-x64" / "cmd" / "git.exe" + staged_git.parent.mkdir(parents=True) + staged_git.touch() + (tmp_path / "checkout" / ".git").mkdir(parents=True) monkeypatch.setattr(compat.sys, "platform", "win32") + monkeypatch.setattr(pm.paths, "store_root", lambda: store) monkeypatch.setenv("PATH", r"C:\Windows\System32") + return tmp_path, staged_git -def test_pm_git_goes_on_path_when_windows_has_none(windows, monkeypatch): +@pytest.mark.parametrize("git_on_path", ["none", "installer-staged"]) +def test_pm_git_is_acquired_and_recorded_when_windows_has_no_git_of_its_own(windows, monkeypatch, git_on_path): + root, staged_git = windows calls = [] store_path = r"C:\store\git-2.53.0+3-win32-x64\cmd;C:\store\git-2.53.0+3-win32-x64\usr\bin;C:\Windows\System32" @@ -22,19 +32,25 @@ def test_pm_git_goes_on_path_when_windows_has_none(windows, monkeypatch): calls.append((name, kwargs)) return Runner(name, {"Path": store_path}) - monkeypatch.setattr(compat.shutil, "which", lambda name, *a, **k: None) + found = str(staged_git) if git_on_path == "installer-staged" else None + monkeypatch.setattr(compat.shutil, "which", lambda name, *a, **k: found) monkeypatch.setattr(pm, "ensure", ensure) - compat.expose_pm_git() + compat.expose_pm_git(root / "checkout") assert calls == [("git", {"explicit": True})] assert os.environ["PATH"] == store_path -def test_a_working_windows_git_is_left_alone(windows, monkeypatch): - monkeypatch.setattr(compat.shutil, "which", lambda name, *a, **k: r"C:\Program Files\Git\cmd\git.exe") - monkeypatch.setattr(pm, "ensure", lambda *a, **k: pytest.fail("acquired PM git over a working one")) +@pytest.mark.parametrize("install", ["own-git", "git-less-zip"]) +def test_own_git_and_git_less_zip_installs_are_left_alone(windows, monkeypatch, install): + root, _ = windows + own_git = str(root / "Git" / "cmd" / "git.exe") if install == "own-git" else None + if install == "git-less-zip": + (root / "checkout" / ".git").rmdir() + monkeypatch.setattr(compat.shutil, "which", lambda name, *a, **k: own_git) + monkeypatch.setattr(pm, "ensure", lambda *a, **k: pytest.fail("acquired PM git")) - compat.expose_pm_git() + compat.expose_pm_git(root / "checkout") assert os.environ["PATH"] == r"C:\Windows\System32"