From de90f5831bcd52c1bacbdf1b698b1717f62d6d56 Mon Sep 17 00:00:00 2001 From: konsisumer Date: Sat, 18 Jul 2026 13:37:53 +0200 Subject: [PATCH] fix(install): continue after autostash restore conflicts --- scripts/install.ps1 | 30 +++- scripts/install.sh | 32 ++++- ...est_install_autostash_conflict_recovery.py | 132 ++++++++++++++++++ 3 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 tests/test_install_autostash_conflict_recovery.py diff --git a/scripts/install.ps1 b/scripts/install.ps1 index be886b366bb..8974519ef4a 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1553,15 +1553,35 @@ function Install-Repository { if ($restoreNow) { Write-Info "Restoring local changes..." - git -c windows.appendAtomically=false stash apply $autostashRef - if ($LASTEXITCODE -eq 0) { + $restoreOutput = @(git -c windows.appendAtomically=false stash apply $autostashRef 2>&1) + $restoreExit = $LASTEXITCODE + $conflictedFiles = @( + git -c windows.appendAtomically=false diff --name-only --diff-filter=U 2>$null + ) | Where-Object { $_ -and $_.ToString().Trim() } + if (($restoreExit -eq 0) -and ($conflictedFiles.Count -eq 0)) { git -c windows.appendAtomically=false stash drop $autostashRef 2>$null Write-Warn "Local changes were restored on top of the updated codebase." Write-Warn "Review git diff / git status if Hermes behaves unexpectedly." } else { - Write-Err "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash." - Write-Info "Resolve manually with: git stash apply $autostashRef" - throw "git stash apply failed after update" + Write-Err "Update pulled new code, but restoring local changes hit conflicts." + foreach ($line in $restoreOutput) { + if ($line -and $line.ToString().Trim()) { + Write-Host $line + } + } + if ($conflictedFiles.Count -gt 0) { + Write-Host "" + Write-Host "Conflicted files:" + foreach ($file in $conflictedFiles) { + Write-Host " • $file" + } + } + Write-Host "" + Write-Info "Your stashed changes are preserved — nothing is lost." + Write-Info " Stash ref: $autostashRef" + git -c windows.appendAtomically=false reset --hard HEAD 2>$null | Out-Null + Write-Info "Working tree reset to clean state." + Write-Info "Restore your changes later with: git stash apply $autostashRef" } } else { Write-Info "Skipped restoring local changes." diff --git a/scripts/install.sh b/scripts/install.sh index 74318165dfc..ef95fc7aecf 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1247,14 +1247,38 @@ clone_repo() { if [ "$restore_now" = "yes" ]; then log_info "Restoring local changes..." - if git stash apply "$autostash_ref"; then + local restore_output="" + local restore_ok="yes" + if restore_output="$(git stash apply "$autostash_ref" 2>&1)"; then + restore_ok="yes" + else + restore_ok="no" + fi + local conflicted_files="" + conflicted_files="$(git diff --name-only --diff-filter=U || true)" + if [ "$restore_ok" = "yes" ] && [ -z "$conflicted_files" ]; then git stash drop "$autostash_ref" >/dev/null log_warn "Local changes were restored on top of the updated codebase." log_warn "Review git diff / git status if Hermes behaves unexpectedly." else - log_error "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash." - log_info "Resolve manually with: git stash apply $autostash_ref" - exit 1 + log_error "Update pulled new code, but restoring local changes hit conflicts." + if [ -n "$restore_output" ]; then + printf '%s\n' "$restore_output" + fi + if [ -n "$conflicted_files" ]; then + printf '\nConflicted files:\n' + while IFS= read -r file; do + [ -n "$file" ] && printf ' • %s\n' "$file" + done </dev/null 2>&1 || true + log_info "Working tree reset to clean state." + log_info "Restore your changes later with: git stash apply $autostash_ref" fi else log_info "Skipped restoring local changes." diff --git a/tests/test_install_autostash_conflict_recovery.py b/tests/test_install_autostash_conflict_recovery.py new file mode 100644 index 00000000000..fe5628145b2 --- /dev/null +++ b/tests/test_install_autostash_conflict_recovery.py @@ -0,0 +1,132 @@ +"""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" + assert (repo / "tracked.txt").read_text(encoding="utf-8") == "upstream edit\n" + + +@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)