fix(update): prompt before resetting working tree on stash conflicts (#2390)

When 'hermes update' stashes local changes and the restore hits
conflicts, the previous behavior silently ran 'git reset --hard HEAD'
to clean up. This could surprise users who didn't realize their
working tree was being nuked.

Now the conflict handler:
- Lists the specific conflicted files
- Reassures the user their stash is preserved
- Asks before resetting (interactive mode)
- Auto-resets in non-interactive mode (prompt_user=False)
- If declined, leaves the working tree as-is with guidance
This commit is contained in:
Teknium
2026-03-21 16:49:19 -07:00
committed by GitHub
parent 525caadd8c
commit c57d5cbdde
2 changed files with 72 additions and 19 deletions

View File

@@ -2570,20 +2570,44 @@ def _restore_stashed_changes(
has_conflicts = bool(unmerged.stdout.strip())
if restore.returncode != 0 or has_conflicts:
# Reset the working tree so Hermes is runnable with the updated code
subprocess.run(
git_cmd + ["reset", "--hard", "HEAD"],
cwd=cwd,
capture_output=True,
)
print("✗ Update pulled new code, but restoring local changes failed.")
print("✗ Update pulled new code, but restoring local changes hit conflicts.")
if restore.stdout.strip():
print(restore.stdout.strip())
if restore.stderr.strip():
print(restore.stderr.strip())
print("The working tree has been reset to a clean state.")
print("Your changes are still preserved in git stash.")
print(f"Resolve manually with: git stash apply {stash_ref}")
# Show which files conflicted
conflicted_files = unmerged.stdout.strip()
if conflicted_files:
print("\nConflicted files:")
for f in conflicted_files.splitlines():
print(f"{f}")
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}")
sys.exit(1)
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)

View File

@@ -210,7 +210,8 @@ 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_exits_cleanly_when_apply_fails(monkeypatch, tmp_path, capsys):
def test_restore_stashed_changes_prompts_before_reset_on_conflict(monkeypatch, tmp_path, capsys):
"""When conflicts occur interactively, user is prompted before reset."""
calls = []
def fake_run(cmd, **kwargs):
@@ -230,16 +231,43 @@ def test_restore_stashed_changes_exits_cleanly_when_apply_fails(monkeypatch, tmp
hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True)
out = capsys.readouterr().out
assert "Your changes are still preserved in git stash." in out
assert "git stash apply abc123" in out
assert "working tree has been reset to a clean state" in out
# Verify reset --hard was called to clean up conflict markers
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
reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]]
assert len(reset_calls) == 1
def test_restore_stashed_changes_resets_when_unmerged_files_detected(monkeypatch, tmp_path, capsys):
"""Even if stash apply returns 0, conflict markers must be cleaned up."""
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."""
calls = []
def fake_run(cmd, **kwargs):
@@ -258,8 +286,9 @@ def test_restore_stashed_changes_resets_when_unmerged_files_detected(monkeypatch
hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False)
out = capsys.readouterr().out
assert "working tree has been reset to a clean state" in out
assert "git stash apply abc123" in out
assert "Working tree reset to clean state" in out
reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]]
assert len(reset_calls) == 1
def test_stash_local_changes_if_needed_raises_when_stash_ref_missing(monkeypatch, tmp_path):