diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a50f33273..024a3e912 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2632,7 +2632,12 @@ def _restore_stashed_changes( print("Resolve conflicts manually, then run: git stash drop") print(f"Restore your changes with: git stash apply {stash_ref}") - sys.exit(1) + # In non-interactive mode (gateway /update), don't abort — the code + # update itself succeeded, only the stash restore had conflicts. + # Aborting would report the entire update as failed. + if prompt_user: + sys.exit(1) + return False stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) if stash_selector is None: @@ -2706,30 +2711,60 @@ def cmd_update(args): # Fetch and pull try: - print("→ Fetching updates...") git_cmd = ["git"] if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] - - subprocess.run(git_cmd + ["fetch", "origin"], cwd=PROJECT_ROOT, check=True) - - # Get current branch + + print("→ Fetching updates...") + fetch_result = subprocess.run( + git_cmd + ["fetch", "origin"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if fetch_result.returncode != 0: + stderr = fetch_result.stderr.strip() + if "Could not resolve host" in stderr or "unable to access" in stderr: + print("✗ Network error — cannot reach the remote repository.") + print(f" {stderr.splitlines()[0]}" if stderr else "") + elif "Authentication failed" in stderr or "could not read Username" in stderr: + print("✗ Authentication failed — check your git credentials or SSH key.") + else: + print(f"✗ Failed to fetch updates from origin.") + if stderr: + print(f" {stderr.splitlines()[0]}") + sys.exit(1) + + # Get current branch (returns literal "HEAD" when detached) result = subprocess.run( git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"], cwd=PROJECT_ROOT, capture_output=True, text=True, - check=True + check=True, ) - branch = result.stdout.strip() + current_branch = result.stdout.strip() - # Fall back to main if the current branch doesn't exist on the remote - verify = subprocess.run( - git_cmd + ["rev-parse", "--verify", f"origin/{branch}"], - cwd=PROJECT_ROOT, capture_output=True, text=True, - ) - if verify.returncode != 0: - branch = "main" + # Always update against main + branch = "main" + + # If user is on a non-main branch or detached HEAD, switch to main + if current_branch != "main": + label = "detached HEAD" if current_branch == "HEAD" else f"branch '{current_branch}'" + print(f" ⚠ Currently on {label} — switching to main for update...") + # Stash before checkout so uncommitted work isn't lost + auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) + subprocess.run( + git_cmd + ["checkout", "main"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=True, + ) + else: + auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) + + prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty() # Check if there are updates result = subprocess.run( @@ -2737,31 +2772,69 @@ def cmd_update(args): cwd=PROJECT_ROOT, capture_output=True, text=True, - check=True + check=True, ) commit_count = int(result.stdout.strip()) - + if commit_count == 0: _invalidate_update_cache() - print("✓ Already up to date!") - return - - print(f"→ Found {commit_count} new commit(s)") - - auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) - prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty() - - print("→ Pulling updates...") - try: - subprocess.run(git_cmd + ["pull", "--ff-only", "origin", branch], cwd=PROJECT_ROOT, check=True) - finally: + # Restore stash and switch back to original branch if we moved if auto_stash_ref is not None: _restore_stashed_changes( - git_cmd, - PROJECT_ROOT, - auto_stash_ref, + git_cmd, PROJECT_ROOT, auto_stash_ref, prompt_user=prompt_for_restore, ) + if current_branch not in ("main", "HEAD"): + subprocess.run( + git_cmd + ["checkout", current_branch], + cwd=PROJECT_ROOT, capture_output=True, text=True, check=False, + ) + print("✓ Already up to date!") + return + + print(f"→ Found {commit_count} new commit(s)") + + print("→ Pulling updates...") + update_succeeded = False + try: + pull_result = subprocess.run( + git_cmd + ["pull", "--ff-only", "origin", branch], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if pull_result.returncode != 0: + # ff-only failed — local and remote have diverged (e.g. upstream + # force-pushed or rebase). Since local changes are already + # stashed, reset to match the remote exactly. + print(" ⚠ Fast-forward not possible (history diverged), resetting to match remote...") + reset_result = subprocess.run( + git_cmd + ["reset", "--hard", f"origin/{branch}"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if reset_result.returncode != 0: + print(f"✗ Failed to reset to origin/{branch}.") + if reset_result.stderr.strip(): + print(f" {reset_result.stderr.strip()}") + print(" Try manually: git fetch origin && git reset --hard origin/main") + sys.exit(1) + update_succeeded = True + finally: + if auto_stash_ref is not None: + # Don't attempt stash restore if the code update itself failed — + # working tree is in an unknown state. + if not update_succeeded: + print(f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})") + print(f" Restore manually with: git stash apply") + else: + _restore_stashed_changes( + git_cmd, + PROJECT_ROOT, + auto_stash_ref, + prompt_user=prompt_for_restore, + ) _invalidate_update_cache() diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 9b8b6d79a..042b4fd47 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -267,7 +267,8 @@ def test_restore_stashed_changes_user_declines_reset(monkeypatch, tmp_path, caps def test_restore_stashed_changes_auto_resets_non_interactive(monkeypatch, tmp_path, capsys): - """Non-interactive mode auto-resets without prompting.""" + """Non-interactive mode auto-resets without prompting and returns False + instead of sys.exit(1) so the update can continue (gateway /update path).""" calls = [] def fake_run(cmd, **kwargs): @@ -282,9 +283,9 @@ def test_restore_stashed_changes_auto_resets_non_interactive(monkeypatch, tmp_pa monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) - with pytest.raises(SystemExit, match="1"): - hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) + result = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) + assert result is False out = capsys.readouterr().out assert "Working tree reset to clean state" in out reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]] @@ -384,3 +385,236 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path): install_cmds = [c for c in recorded if "pip" in c and "install" in c] assert len(install_cmds) == 1 assert ".[all]" in install_cmds[0] + + +# --------------------------------------------------------------------------- +# ff-only fallback to reset --hard on diverged history +# --------------------------------------------------------------------------- + +def _make_update_side_effect( + current_branch="main", + commit_count="3", + ff_only_fails=False, + reset_fails=False, + fetch_fails=False, + fetch_stderr="", +): + """Build a subprocess.run side_effect for cmd_update tests.""" + recorded = [] + + def side_effect(cmd, **kwargs): + recorded.append(cmd) + joined = " ".join(str(c) for c in cmd) + if "fetch" in joined and "origin" in joined: + if fetch_fails: + return SimpleNamespace(stdout="", stderr=fetch_stderr, returncode=128) + return SimpleNamespace(stdout="", stderr="", returncode=0) + if "rev-parse" in joined and "--abbrev-ref" in joined: + return SimpleNamespace(stdout=f"{current_branch}\n", stderr="", returncode=0) + if "checkout" in joined and "main" in joined: + return SimpleNamespace(stdout="", stderr="", returncode=0) + if "rev-list" in joined: + return SimpleNamespace(stdout=f"{commit_count}\n", stderr="", returncode=0) + if "--ff-only" in joined: + if ff_only_fails: + return SimpleNamespace( + stdout="", + stderr="fatal: Not possible to fast-forward, aborting.\n", + returncode=128, + ) + return SimpleNamespace(stdout="Updating abc..def\n", stderr="", returncode=0) + if "reset" in joined and "--hard" in joined: + if reset_fails: + return SimpleNamespace(stdout="", stderr="error: unable to write\n", returncode=1) + return SimpleNamespace(stdout="HEAD is now at abc123\n", stderr="", returncode=0) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + return side_effect, recorded + + +def test_cmd_update_falls_back_to_reset_when_ff_only_fails(monkeypatch, tmp_path, capsys): + """When --ff-only fails (diverged history), update resets to origin/{branch}.""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + + side_effect, recorded = _make_update_side_effect(ff_only_fails=True) + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + hermes_main.cmd_update(SimpleNamespace()) + + reset_calls = [c for c in recorded if "reset" in c and "--hard" in c] + assert len(reset_calls) == 1 + assert reset_calls[0] == ["git", "reset", "--hard", "origin/main"] + + out = capsys.readouterr().out + assert "Fast-forward not possible" in out + + +def test_cmd_update_no_reset_when_ff_only_succeeds(monkeypatch, tmp_path): + """When --ff-only succeeds, no reset is attempted.""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + + side_effect, recorded = _make_update_side_effect() + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + hermes_main.cmd_update(SimpleNamespace()) + + reset_calls = [c for c in recorded if "reset" in c and "--hard" in c] + assert len(reset_calls) == 0 + + +# --------------------------------------------------------------------------- +# Non-main branch → auto-checkout main +# --------------------------------------------------------------------------- + +def test_cmd_update_switches_to_main_from_feature_branch(monkeypatch, tmp_path, capsys): + """When on a feature branch, update checks out main before pulling.""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + + side_effect, recorded = _make_update_side_effect(current_branch="fix/something") + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + hermes_main.cmd_update(SimpleNamespace()) + + checkout_calls = [c for c in recorded if "checkout" in c and "main" in c] + assert len(checkout_calls) == 1 + + out = capsys.readouterr().out + assert "fix/something" in out + assert "switching to main" in out + + +def test_cmd_update_switches_to_main_from_detached_head(monkeypatch, tmp_path, capsys): + """When in detached HEAD state, update checks out main before pulling.""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + + side_effect, recorded = _make_update_side_effect(current_branch="HEAD") + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + hermes_main.cmd_update(SimpleNamespace()) + + checkout_calls = [c for c in recorded if "checkout" in c and "main" in c] + assert len(checkout_calls) == 1 + + out = capsys.readouterr().out + assert "detached HEAD" in out + + +def test_cmd_update_restores_stash_and_branch_when_already_up_to_date(monkeypatch, tmp_path, capsys): + """When on a feature branch with no updates, stash is restored and branch switched back.""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + + # Enable stash so it returns a ref + monkeypatch.setattr( + hermes_main, "_stash_local_changes_if_needed", + lambda *a, **kw: "abc123deadbeef", + ) + restore_calls = [] + monkeypatch.setattr( + hermes_main, "_restore_stashed_changes", + lambda *a, **kw: restore_calls.append(1) or True, + ) + + side_effect, recorded = _make_update_side_effect( + current_branch="fix/something", commit_count="0", + ) + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + hermes_main.cmd_update(SimpleNamespace()) + + # Stash should have been restored + assert len(restore_calls) == 1 + + # Should have checked out back to the original branch + checkout_back = [c for c in recorded if "checkout" in c and "fix/something" in c] + assert len(checkout_back) == 1 + + out = capsys.readouterr().out + assert "Already up to date" in out + + +def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path): + """When already on main, no checkout is needed.""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + + side_effect, recorded = _make_update_side_effect() + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + hermes_main.cmd_update(SimpleNamespace()) + + checkout_calls = [c for c in recorded if "checkout" in c] + assert len(checkout_calls) == 0 + + +# --------------------------------------------------------------------------- +# Fetch failure — friendly error messages +# --------------------------------------------------------------------------- + +def test_cmd_update_network_error_shows_friendly_message(monkeypatch, tmp_path, capsys): + """Network failures during fetch show a user-friendly message.""" + _setup_update_mocks(monkeypatch, tmp_path) + + side_effect, _ = _make_update_side_effect( + fetch_fails=True, + fetch_stderr="fatal: unable to access 'https://...': Could not resolve host: github.com", + ) + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + with pytest.raises(SystemExit, match="1"): + hermes_main.cmd_update(SimpleNamespace()) + + out = capsys.readouterr().out + assert "Network error" in out + + +def test_cmd_update_auth_error_shows_friendly_message(monkeypatch, tmp_path, capsys): + """Auth failures during fetch show a user-friendly message.""" + _setup_update_mocks(monkeypatch, tmp_path) + + side_effect, _ = _make_update_side_effect( + fetch_fails=True, + fetch_stderr="fatal: Authentication failed for 'https://...'", + ) + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + with pytest.raises(SystemExit, match="1"): + hermes_main.cmd_update(SimpleNamespace()) + + out = capsys.readouterr().out + assert "Authentication failed" in out + + +# --------------------------------------------------------------------------- +# reset --hard failure — don't attempt stash restore +# --------------------------------------------------------------------------- + +def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path, capsys): + """When reset --hard fails, stash restore is skipped with a helpful message.""" + _setup_update_mocks(monkeypatch, tmp_path) + # Re-enable stash so it actually returns a ref + monkeypatch.setattr( + hermes_main, "_stash_local_changes_if_needed", + lambda *a, **kw: "abc123deadbeef", + ) + restore_calls = [] + monkeypatch.setattr( + hermes_main, "_restore_stashed_changes", + lambda *a, **kw: restore_calls.append(1) or True, + ) + + side_effect, _ = _make_update_side_effect(ff_only_fails=True, reset_fails=True) + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + with pytest.raises(SystemExit, match="1"): + hermes_main.cmd_update(SimpleNamespace()) + + # Stash restore should NOT have been called + assert len(restore_calls) == 0 + + out = capsys.readouterr().out + assert "preserved in stash" in out