fix(update): reset working tree when stash restore leaves conflict markers

When `hermes update` stashes local changes and the subsequent
`git stash apply` fails or leaves unmerged files, the conflict markers
(<<<<<<< etc.) were left in the working tree, making Hermes unrunnable
until manually cleaned up.

Now the update command runs `git reset --hard HEAD` to restore a clean
working tree before exiting, and also detects unmerged files even when
git stash apply reports success.

Closes #2348
This commit is contained in:
Teknium
2026-03-21 16:16:35 -07:00
parent f8fb61d4ad
commit 3ab50376b0
2 changed files with 68 additions and 11 deletions

View File

@@ -2559,12 +2559,29 @@ def _restore_stashed_changes(
capture_output=True,
text=True,
)
if restore.returncode != 0:
# Check for unmerged (conflicted) files — can happen even when returncode is 0
unmerged = subprocess.run(
git_cmd + ["diff", "--name-only", "--diff-filter=U"],
cwd=cwd,
capture_output=True,
text=True,
)
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.")
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}")
sys.exit(1)

View File

@@ -68,6 +68,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] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="", 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"]:
@@ -81,8 +83,9 @@ 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", "list", "--format=%gd %H"]
assert calls[2][0] == ["git", "stash", "drop", "stash@{1}"]
assert calls[1][0] == ["git", "diff", "--name-only", "--diff-filter=U"]
assert calls[2][0] == ["git", "stash", "list", "--format=%gd %H"]
assert calls[3][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
@@ -117,6 +120,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] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="", 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"]:
@@ -129,8 +134,9 @@ 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 calls[1][0] == ["git", "diff", "--name-only", "--diff-filter=U"]
assert calls[2][0] == ["git", "stash", "list", "--format=%gd %H"]
assert calls[3][0] == ["git", "stash", "drop", "stash@{0}"]
assert "Restore local changes now?" not in capsys.readouterr().out
@@ -152,6 +158,8 @@ def test_restore_stashed_changes_keeps_going_when_stash_entry_cannot_be_resolved
calls.append((cmd, kwargs))
if cmd[1:3] == ["stash", "apply"]:
return SimpleNamespace(stdout="applied\n", stderr="", returncode=0)
if cmd[1:3] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="", 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}")
@@ -161,10 +169,9 @@ def test_restore_stashed_changes_keeps_going_when_stash_entry_cannot_be_resolved
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}),
]
assert calls[0] == (["git", "stash", "apply", "abc123"], {"cwd": tmp_path, "capture_output": True, "text": True})
assert calls[1] == (["git", "diff", "--name-only", "--diff-filter=U"], {"cwd": tmp_path, "capture_output": True, "text": True})
assert calls[2] == (["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
@@ -181,6 +188,8 @@ def test_restore_stashed_changes_keeps_going_when_drop_fails(monkeypatch, tmp_pa
calls.append((cmd, kwargs))
if cmd[1:3] == ["stash", "apply"]:
return SimpleNamespace(stdout="applied\n", stderr="", returncode=0)
if cmd[1:3] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="", 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"]:
@@ -192,7 +201,7 @@ def test_restore_stashed_changes_keeps_going_when_drop_fails(monkeypatch, tmp_pa
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}"]
assert calls[3][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
@@ -208,6 +217,10 @@ def test_restore_stashed_changes_exits_cleanly_when_apply_fails(monkeypatch, tmp
calls.append((cmd, kwargs))
if cmd[1:3] == ["stash", "apply"]:
return SimpleNamespace(stdout="conflict output\n", stderr="conflict stderr\n", returncode=1)
if cmd[1:3] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="hermes_cli/main.py\n", stderr="", returncode=0)
if cmd[1:3] == ["reset", "--hard"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
raise AssertionError(f"unexpected command: {cmd}")
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
@@ -219,7 +232,34 @@ def test_restore_stashed_changes_exits_cleanly_when_apply_fails(monkeypatch, tmp
out = capsys.readouterr().out
assert "Your changes are still preserved in git stash." in out
assert "git stash apply abc123" in out
assert calls == [(["git", "stash", "apply", "abc123"], {"cwd": tmp_path, "capture_output": True, "text": True})]
assert "working tree has been reset to a clean state" in out
# Verify reset --hard was called to clean up conflict markers
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."""
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] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="cli.py\n", stderr="", returncode=0)
if cmd[1:3] == ["reset", "--hard"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
raise AssertionError(f"unexpected command: {cmd}")
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)
out = capsys.readouterr().out
assert "working tree has been reset to a clean state" in out
assert "git stash apply abc123" in out
def test_stash_local_changes_if_needed_raises_when_stash_ref_missing(monkeypatch, tmp_path):