"""Regression: installer autostash restore conflicts must not abort the run. An interrupted/repeated managed install can leave local tracked edits in the checkout. If upstream then changes the same lines, ``git stash apply`` conflicts during the repository-update stage. Both installers must leave the stash intact, reset the worktree clean, and complete the real repository stage. """ from __future__ import annotations import os import shutil import subprocess from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" POWERSHELL = next( (candidate for candidate in ("pwsh", "powershell") if shutil.which(candidate)), None, ) def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess: return subprocess.run( ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], cwd=cwd, check=check, capture_output=True, text=True, ) def _make_conflicted_managed_checkout(tmp_path: Path) -> Path: """Create a managed checkout whose autostash conflicts with its origin.""" seed = tmp_path / "seed" seed.mkdir() _git(seed, "init") (seed / "tracked.txt").write_text("base\n", encoding="utf-8") _git(seed, "add", "tracked.txt") _git(seed, "commit", "-m", "base") _git(seed, "branch", "-M", "main") remote = tmp_path / "origin.git" _git(tmp_path, "init", "--bare", str(remote)) _git(seed, "remote", "add", "origin", str(remote)) _git(seed, "push", "-u", "origin", "main") managed = tmp_path / "hermes-agent" _git(tmp_path, "clone", "--branch", "main", str(remote), str(managed)) (managed / "tracked.txt").write_text("local edit\n", encoding="utf-8") upstream = tmp_path / "upstream" _git(tmp_path, "clone", "--branch", "main", str(remote), str(upstream)) (upstream / "tracked.txt").write_text("upstream edit\n", encoding="utf-8") _git(upstream, "commit", "-am", "upstream") _git(upstream, "push", "origin", "main") return managed def _assert_conflict_was_recovered(repo: Path, output: str) -> None: assert "restoring local changes hit conflicts" in output assert "Conflicted files:" in output assert "tracked.txt" in output assert "Working tree reset to clean state." in output assert "Restore your changes later with: git stash apply stash@{0}" in output assert _git(repo, "status", "--porcelain").stdout.strip() == "" assert _git(repo, "stash", "list").stdout.strip(), "stash must be preserved" content = (repo / "tracked.txt").read_text(encoding="utf-8") assert content == "upstream edit\n", content # No conflict markers must be left in tracked source — they would crash # the backend on import (SyntaxError on the <<<<<<< line). assert "<<<<<<<" not in content and ">>>>>>>" not in content @pytest.mark.live_system_guard_bypass @pytest.mark.skipif( shutil.which("git") is None or shutil.which("bash") is None, reason="needs git and bash", ) def test_install_sh_repository_stage_recovers_from_autostash_conflict( tmp_path: Path, ) -> None: managed = _make_conflicted_managed_checkout(tmp_path) env = os.environ | { "HERMES_HOME": str(tmp_path / "hermes-home"), "HERMES_INSTALL_DIR": str(managed), } result = subprocess.run( ["bash", str(INSTALL_SH), "--stage", "repository", "--non-interactive"], cwd=tmp_path, env=env, capture_output=True, text=True, ) assert result.returncode == 0, result.stderr _assert_conflict_was_recovered(managed, result.stdout) @pytest.mark.live_system_guard_bypass @pytest.mark.skipif( shutil.which("git") is None or POWERSHELL is None, reason="needs git and PowerShell", ) def test_install_ps1_repository_stage_recovers_from_autostash_conflict( tmp_path: Path, ) -> None: managed = _make_conflicted_managed_checkout(tmp_path) result = subprocess.run( [ POWERSHELL, "-NoProfile", "-File", str(INSTALL_PS1), "-Stage", "repository", "-NonInteractive", "-InstallDir", str(managed), "-HermesHome", str(tmp_path / "hermes-home"), ], cwd=tmp_path, capture_output=True, text=True, ) assert result.returncode == 0, result.stderr _assert_conflict_was_recovered(managed, result.stdout) @pytest.mark.live_system_guard_bypass @pytest.mark.skipif( shutil.which("git") is None or shutil.which("bash") is None, reason="needs git and bash", ) def test_install_sh_repository_stage_clean_apply_drops_stash( tmp_path: Path, ) -> None: """Happy path: a non-conflicting restore must still apply and drop the stash. The conflict-recovery fix must not regress the normal path — when stash apply succeeds cleanly, the stash should be dropped and local changes restored. """ seed = tmp_path / "seed" seed.mkdir() _git(seed, "init") (seed / "tracked.txt").write_text("base\n", encoding="utf-8") _git(seed, "add", "tracked.txt") _git(seed, "commit", "-m", "base") _git(seed, "branch", "-M", "main") remote = tmp_path / "origin.git" _git(tmp_path, "init", "--bare", str(remote)) _git(seed, "remote", "add", "origin", str(remote)) _git(seed, "push", "-u", "origin", "main") managed = tmp_path / "hermes-agent" _git(tmp_path, "clone", "--branch", "main", str(remote), str(managed)) # Local edit on a file upstream will NOT touch — no conflict on apply. (managed / "local-only.txt").write_text("local edit\n", encoding="utf-8") upstream = tmp_path / "upstream" _git(tmp_path, "clone", "--branch", "main", str(remote), str(upstream)) (upstream / "tracked.txt").write_text("upstream edit\n", encoding="utf-8") _git(upstream, "commit", "-am", "upstream") _git(upstream, "push", "origin", "main") env = os.environ | { "HERMES_HOME": str(tmp_path / "hermes-home"), "HERMES_INSTALL_DIR": str(managed), } result = subprocess.run( ["bash", str(INSTALL_SH), "--stage", "repository", "--non-interactive"], cwd=tmp_path, env=env, capture_output=True, text=True, ) assert result.returncode == 0, result.stderr assert "Local changes were restored on top of the updated codebase." in result.stdout # Stash must be dropped on a clean apply — not preserved. assert _git(managed, "stash", "list").stdout.strip() == "", "stash must be dropped on clean apply" # Local changes must be present in the working tree. assert (managed / "local-only.txt").read_text(encoding="utf-8") == "local edit\n" assert (managed / "tracked.txt").read_text(encoding="utf-8") == "upstream edit\n"