From 47c5c976544dabf3db057e9393b0bdcf42942757 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:45:29 -0700 Subject: [PATCH] fix(update): drop autostash by stash selector --- hermes_cli/main.py | 38 +++++++++++- tests/hermes_cli/test_update_autostash.py | 76 ++++++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1238d9b6a..46745a5e5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1997,6 +1997,22 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st +def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Optional[str]: + stash_list = subprocess.run( + git_cmd + ["stash", "list", "--format=%gd %H"], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ) + for line in stash_list.stdout.splitlines(): + selector, _, commit = line.partition(" ") + if commit.strip() == stash_ref: + return selector.strip() + return None + + + def _restore_stashed_changes( git_cmd: list[str], cwd: Path, @@ -2033,7 +2049,27 @@ def _restore_stashed_changes( print(f"Resolve manually with: git stash apply {stash_ref}") sys.exit(1) - subprocess.run(git_cmd + ["stash", "drop", stash_ref], cwd=cwd, check=True) + stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) + if stash_selector is None: + print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.") + print(" The stash was left in place. You can remove it manually after checking the result.") + print(f" Look for commit {stash_ref} in `git stash list --format='%gd %H'` and drop that selector.") + else: + drop = subprocess.run( + git_cmd + ["stash", "drop", stash_selector], + cwd=cwd, + capture_output=True, + text=True, + ) + if drop.returncode != 0: + print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.") + if drop.stdout.strip(): + print(drop.stdout.strip()) + if drop.stderr.strip(): + print(drop.stderr.strip()) + print(" The stash was left in place. You can remove it manually after checking the result.") + print(f" If needed: git stash drop {stash_selector}") + print("⚠ Local changes were restored on top of the updated codebase.") print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.") return True diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index a05a5cbcf..f5a25e971 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -46,6 +46,20 @@ def test_stash_local_changes_if_needed_returns_specific_stash_commit(monkeypatch assert calls[2][0][-3:] == ["rev-parse", "--verify", "refs/stash"] +def test_resolve_stash_selector_returns_matching_entry(monkeypatch, tmp_path): + def fake_run(cmd, **kwargs): + assert cmd == ["git", "stash", "list", "--format=%gd %H"] + return SimpleNamespace( + stdout="stash@{0} def456\nstash@{1} abc123\n", + returncode=0, + ) + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + assert hermes_main._resolve_stash_selector(["git"], tmp_path, "abc123") == "stash@{1}" + + + def test_restore_stashed_changes_prompts_before_applying(monkeypatch, tmp_path, capsys): calls = [] @@ -53,6 +67,8 @@ def test_restore_stashed_changes_prompts_before_applying(monkeypatch, tmp_path, calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) + if cmd[1:3] == ["stash", "list"]: + return SimpleNamespace(stdout="stash@{1} abc123\n", stderr="", returncode=0) if cmd[1:3] == ["stash", "drop"]: return SimpleNamespace(stdout="dropped\n", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") @@ -64,7 +80,8 @@ def test_restore_stashed_changes_prompts_before_applying(monkeypatch, tmp_path, assert restored is True assert calls[0][0] == ["git", "stash", "apply", "abc123"] - assert calls[1][0] == ["git", "stash", "drop", "abc123"] + assert calls[1][0] == ["git", "stash", "list", "--format=%gd %H"] + assert calls[2][0] == ["git", "stash", "drop", "stash@{1}"] out = capsys.readouterr().out assert "Restore local changes now? [Y/n]" in out assert "restored on top of the updated codebase" in out @@ -99,6 +116,8 @@ def test_restore_stashed_changes_applies_without_prompt_when_disabled(monkeypatc calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) + if cmd[1:3] == ["stash", "list"]: + return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0) if cmd[1:3] == ["stash", "drop"]: return SimpleNamespace(stdout="dropped\n", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") @@ -109,9 +128,64 @@ def test_restore_stashed_changes_applies_without_prompt_when_disabled(monkeypatc assert restored is True assert calls[0][0] == ["git", "stash", "apply", "abc123"] + assert calls[1][0] == ["git", "stash", "list", "--format=%gd %H"] + assert calls[2][0] == ["git", "stash", "drop", "stash@{0}"] assert "Restore local changes now?" not in capsys.readouterr().out + +def test_restore_stashed_changes_keeps_going_when_stash_entry_cannot_be_resolved(monkeypatch, tmp_path, capsys): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + if cmd[1:3] == ["stash", "apply"]: + return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) + if cmd[1:3] == ["stash", "list"]: + return SimpleNamespace(stdout="stash@{0} def456\n", stderr="", returncode=0) + raise AssertionError(f"unexpected command: {cmd}") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) + + assert restored is True + assert calls == [ + (["git", "stash", "apply", "abc123"], {"cwd": tmp_path, "capture_output": True, "text": True}), + (["git", "stash", "list", "--format=%gd %H"], {"cwd": tmp_path, "capture_output": True, "text": True, "check": True}), + ] + out = capsys.readouterr().out + assert "couldn't find the stash entry to drop" in out + assert "stash was left in place" in out + assert "Look for commit abc123" in out + + + +def test_restore_stashed_changes_keeps_going_when_drop_fails(monkeypatch, tmp_path, capsys): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + if cmd[1:3] == ["stash", "apply"]: + return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) + if cmd[1:3] == ["stash", "list"]: + return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0) + if cmd[1:3] == ["stash", "drop"]: + return SimpleNamespace(stdout="", stderr="drop failed\n", returncode=1) + raise AssertionError(f"unexpected command: {cmd}") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) + + assert restored is True + assert calls[2][0] == ["git", "stash", "drop", "stash@{0}"] + out = capsys.readouterr().out + assert "couldn't drop the saved stash entry" in out + assert "drop failed" in out + assert "git stash drop stash@{0}" in out + + def test_restore_stashed_changes_exits_cleanly_when_apply_fails(monkeypatch, tmp_path, capsys): calls = []