diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 802a2abbf7..5460926ff2 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -443,8 +443,6 @@ function Get-Uv { # the same store slot (\git--\) pm uses. Returns the # git.exe path, or $null when no pinned artifact exists for this target. function Get-PinnedGit { - $existing = Get-Command git -ErrorAction SilentlyContinue - if ($existing) { return $existing.Source } # dev shortcut; fetches nothing $target = "win32-$(Get-WindowsArch)" $pin = $script:GitPinFiles[$target] if (-not $pin) { return $null } @@ -482,18 +480,14 @@ function Get-PinnedGit { return $gitExe } -# Ensure a usable git for the rest of the ladder: pinned pm store slot -# first, then PATH. Returns $true on success. +# Each -Stage is a new PowerShell process. Restore the pinned pm store Git +# PATH in every stage that invokes git; never inherit an unpinned system Git. function Ensure-Git { $g = Get-PinnedGit if (-not $g) { return $false } - if ($g -ne "git") { - # Store-staged git: expose cmd + usr\bin on this process's PATH so - # bare `git` works for the rest of the ladder (the same dirs pm's - # git package env() composes). - $gitEntry = Split-Path (Split-Path $g -Parent) -Parent - $env:Path = "$gitEntry\cmd;$gitEntry\usr\bin;$env:Path" - } + # The same dirs pm's git package env() composes. + $gitEntry = Split-Path (Split-Path $g -Parent) -Parent + $env:Path = "$gitEntry\cmd;$gitEntry\usr\bin;$env:Path" return $true } @@ -552,12 +546,13 @@ $Stages = @( $Stages += @{ name = "complete"; title = "Finish install"; category = "runtime"; needs_user_input = $false } function Stage-Prerequisites { if (-not (Ensure-Git)) { - Fail "git is required. Install Git for Windows: https://git-scm.com/download/win" + Fail "no pinned Git artifact for this Windows architecture" } Log "prerequisites ok (git)" } function Stage-Repository { + if (-not (Ensure-Git)) { Fail "no pinned Git artifact for this Windows architecture" } # An interrupted clone from an older installer can leave a .git with no # initial commit, where stash/checkout abort ("You do not have the initial # commit yet", #40998). Move it aside -- never delete it, it may hold @@ -886,7 +881,10 @@ function Confirm-DesktopArtifact { function Stage-Complete { $commit = $Commit - if (-not $commit) { $commit = Invoke-Native { git -C $InstallDir rev-parse HEAD 2>$null } } + if (-not $commit) { + if (-not (Ensure-Git)) { Fail "no pinned Git artifact for this Windows architecture" } + $commit = Invoke-Native { git -C $InstallDir rev-parse HEAD 2>$null } + } if ($commit) { $marker = [ordered]@{ schemaVersion = 1 diff --git a/tests/scripts/install/test_install_ps1_staged_git.py b/tests/scripts/install/test_install_ps1_staged_git.py new file mode 100644 index 0000000000..30fad69734 --- /dev/null +++ b/tests/scripts/install/test_install_ps1_staged_git.py @@ -0,0 +1,60 @@ +"""The bootstrap's separately launched stages must each use PM's pinned Git.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + + +INSTALLER = Path(__file__).resolve().parents[3] / "scripts" / "install.ps1" + + +@pytest.mark.platforms("windows") +def test_stage_processes_restore_pinned_git_and_never_fall_back(tmp_path): + powershell = shutil.which("powershell.exe") + system_git = shutil.which("git") + assert powershell and system_git + + origin = tmp_path / "origin" + origin.mkdir() + subprocess.run([system_git, "-C", str(origin), "init", "-q", "-b", "main"], check=True) + (origin / "README").write_text("fixture", encoding="utf-8") + subprocess.run([system_git, "-C", str(origin), "add", "README"], check=True) + subprocess.run([system_git, "-C", str(origin), "-c", "user.name=Smoke", + "-c", "user.email=smoke@example.invalid", "commit", "-qm", "initial"], check=True) + expected = subprocess.run([system_git, "-C", str(origin), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True).stdout.strip() + + home = tmp_path / "home" + store = tmp_path / "tools" + env = dict(os.environ, HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(store), + HERMES_REPO_URL=str(origin)) + + def stage(name): + result = subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", str(INSTALLER), "-Stage", name, "-Json"], + env=env, capture_output=True, text=True, timeout=240) + frames = [json.loads(line) for line in result.stdout.splitlines() if line.startswith("{")] + assert result.returncode == 0 and len(frames) == 1 and frames[0]["ok"], result.stdout + result.stderr + + stage("prerequisites") + staged = list(store.glob("git-*/cmd/git.exe")) + assert len(staged) == 1 + stage("repository") # new process; prerequisites' PATH cannot propagate + stage("complete") # the marker's bare git call is also a new process + checkout = home / "hermes-agent" + actual = subprocess.run([str(staged[0]), "-C", str(checkout), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True).stdout.strip() + marker = json.loads((checkout / ".hermes-bootstrap-complete").read_text(encoding="utf-8-sig")) + assert actual == expected == marker["pinnedCommit"] + + # Even when system Git is on PATH, an unsupported pin must fail closed. + probe = (f". '{INSTALLER}'; " + "$script:GitPinFiles.Remove(('win32-' + (Get-WindowsArch))); " + "if (Ensure-Git) { exit 1 } else { exit 0 }") + refused = subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-Command", probe], env=env, capture_output=True, text=True, timeout=30) + assert refused.returncode == 0, refused.stdout + refused.stderr