From 0848a79476e5fe52354287a93ef48f262908127c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:32:20 -0700 Subject: [PATCH] =?UTF-8?q?fix(update):=20always=20reset=20on=20stash=20co?= =?UTF-8?q?nflict=20=E2=80=94=20never=20leave=20conflict=20markers=20(#701?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `hermes update` stashes local changes and the restore hits merge conflicts, the old code prompted the user to reset or keep conflict markers. If the user declined the reset, git conflict markers (<<<<<<< Updated upstream) were left in source files, making hermes completely unrunnable with a SyntaxError on the next invocation. Additionally, the interactive path called sys.exit(1), which killed the entire update process before pip dependency install, skill sync, and gateway restart could finish — even though the code pull itself had succeeded. Changes: - Always auto-reset to clean state when stash restore conflicts - Remove the "Reset working tree?" prompt (footgun) - Remove sys.exit(1) — return False so cmd_update continues normally - User's changes remain safely in the stash for manual recovery Also fixes a secondary bug where the conflict handling prompt used bare input() instead of the input_fn parameter, which would hang in gateway mode. Tests updated: replaced prompt/sys.exit assertions with auto-reset behavior checks; removed the "user declines reset" test (path no longer exists). --- hermes_cli/main.py | 40 ++++++++--------------- tests/hermes_cli/test_update_autostash.py | 40 +++++------------------ 2 files changed, 22 insertions(+), 58 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7d4a4a924..72d660bac 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3022,33 +3022,19 @@ def _restore_stashed_changes( print("\nYour stashed changes are preserved — nothing is lost.") print(f" Stash ref: {stash_ref}") - # Ask before resetting (if interactive) - do_reset = True - if prompt_user: - print("\nReset working tree to clean state so Hermes can run?") - print(" (You can re-apply your changes later with: git stash apply)") - print("[Y/n] ", end="", flush=True) - response = input().strip().lower() - if response not in ("", "y", "yes"): - do_reset = False - - if do_reset: - subprocess.run( - git_cmd + ["reset", "--hard", "HEAD"], - cwd=cwd, - capture_output=True, - ) - print("Working tree reset to clean state.") - else: - print("Working tree left as-is (may have conflict markers).") - print("Resolve conflicts manually, then run: git stash drop") - - print(f"Restore your changes with: git stash apply {stash_ref}") - # 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) + # Always reset to clean state — leaving conflict markers in source + # files makes hermes completely unrunnable (SyntaxError on import). + # The user's changes are safe in the stash for manual recovery. + subprocess.run( + git_cmd + ["reset", "--hard", "HEAD"], + cwd=cwd, + capture_output=True, + ) + print("Working tree reset to clean state.") + print(f"Restore your changes later with: git stash apply {stash_ref}") + # Don't sys.exit — the code update itself succeeded, only the stash + # restore had conflicts. Let cmd_update continue with pip install, + # skill sync, and gateway restart. return False stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index f97c6c35f..dee8cc1fb 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -213,8 +213,12 @@ def test_restore_stashed_changes_keeps_going_when_drop_fails(monkeypatch, tmp_pa assert "git stash drop stash@{0}" in out -def test_restore_stashed_changes_prompts_before_reset_on_conflict(monkeypatch, tmp_path, capsys): - """When conflicts occur interactively, user is prompted before reset.""" +def test_restore_stashed_changes_always_resets_on_conflict(monkeypatch, tmp_path, capsys): + """Conflicts always auto-reset (no prompt) and return False, even interactively. + + Leaving conflict markers in source files makes hermes unrunnable (SyntaxError). + The stash is preserved for manual recovery; cmd_update continues normally. + """ calls = [] def fake_run(cmd, **kwargs): @@ -230,45 +234,19 @@ def test_restore_stashed_changes_prompts_before_reset_on_conflict(monkeypatch, t monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) monkeypatch.setattr("builtins.input", lambda: "y") - with pytest.raises(SystemExit, match="1"): - hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True) + result = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True) + assert result is False out = capsys.readouterr().out assert "Conflicted files:" in out assert "hermes_cli/main.py" in out assert "stashed changes are preserved" in out - assert "Reset working tree to clean state" in out assert "Working tree reset to clean state" in out + assert "git stash apply abc123" in out reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]] assert len(reset_calls) == 1 -def test_restore_stashed_changes_user_declines_reset(monkeypatch, tmp_path, capsys): - """When user declines reset, working tree is left as-is.""" - calls = [] - - def fake_run(cmd, **kwargs): - calls.append((cmd, kwargs)) - if cmd[1:3] == ["stash", "apply"]: - return SimpleNamespace(stdout="", stderr="conflict\n", returncode=1) - if cmd[1:3] == ["diff", "--name-only"]: - return SimpleNamespace(stdout="cli.py\n", stderr="", returncode=0) - raise AssertionError(f"unexpected command: {cmd}") - - monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) - # First input: "y" to restore, second input: "n" to decline reset - inputs = iter(["y", "n"]) - monkeypatch.setattr("builtins.input", lambda: next(inputs)) - - with pytest.raises(SystemExit, match="1"): - hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True) - - out = capsys.readouterr().out - assert "left as-is" in out - reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]] - assert len(reset_calls) == 0 - - def test_restore_stashed_changes_auto_resets_non_interactive(monkeypatch, tmp_path, capsys): """Non-interactive mode auto-resets without prompting and returns False instead of sys.exit(1) so the update can continue (gateway /update path)."""