fix(update): always reset on stash conflict — never leave conflict markers (#7010)

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).
This commit is contained in:
Teknium
2026-04-10 00:32:20 -07:00
committed by GitHub
parent 871313ae2d
commit 0848a79476
2 changed files with 22 additions and 58 deletions

View File

@@ -3022,33 +3022,19 @@ def _restore_stashed_changes(
print("\nYour stashed changes are preserved — nothing is lost.") print("\nYour stashed changes are preserved — nothing is lost.")
print(f" Stash ref: {stash_ref}") print(f" Stash ref: {stash_ref}")
# Ask before resetting (if interactive) # Always reset to clean state — leaving conflict markers in source
do_reset = True # files makes hermes completely unrunnable (SyntaxError on import).
if prompt_user: # The user's changes are safe in the stash for manual recovery.
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( subprocess.run(
git_cmd + ["reset", "--hard", "HEAD"], git_cmd + ["reset", "--hard", "HEAD"],
cwd=cwd, cwd=cwd,
capture_output=True, capture_output=True,
) )
print("Working tree reset to clean state.") print("Working tree reset to clean state.")
else: print(f"Restore your changes later with: git stash apply {stash_ref}")
print("Working tree left as-is (may have conflict markers).") # Don't sys.exit — the code update itself succeeded, only the stash
print("Resolve conflicts manually, then run: git stash drop") # restore had conflicts. Let cmd_update continue with pip install,
# skill sync, and gateway restart.
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)
return False return False
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)

View File

@@ -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 assert "git stash drop stash@{0}" in out
def test_restore_stashed_changes_prompts_before_reset_on_conflict(monkeypatch, tmp_path, capsys): def test_restore_stashed_changes_always_resets_on_conflict(monkeypatch, tmp_path, capsys):
"""When conflicts occur interactively, user is prompted before reset.""" """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 = [] calls = []
def fake_run(cmd, **kwargs): 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(hermes_main.subprocess, "run", fake_run)
monkeypatch.setattr("builtins.input", lambda: "y") monkeypatch.setattr("builtins.input", lambda: "y")
with pytest.raises(SystemExit, match="1"): result = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True)
hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True)
assert result is False
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Conflicted files:" in out assert "Conflicted files:" in out
assert "hermes_cli/main.py" in out assert "hermes_cli/main.py" in out
assert "stashed changes are preserved" 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 "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"]] reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]]
assert len(reset_calls) == 1 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): def test_restore_stashed_changes_auto_resets_non_interactive(monkeypatch, tmp_path, capsys):
"""Non-interactive mode auto-resets without prompting and returns False """Non-interactive mode auto-resets without prompting and returns False
instead of sys.exit(1) so the update can continue (gateway /update path).""" instead of sys.exit(1) so the update can continue (gateway /update path)."""