fix: harden hermes update against diverged history, non-main branches, and gateway edge cases (salvage #3489) (#3492)
* fix: harden `hermes update` against diverged history, non-main branches, and gateway edge cases
The self-update command (`hermes update` / gateway `/update`) could fail
or silently corrupt state in several scenarios:
1. **Diverged history** — `git pull --ff-only` aborts with a cryptic
subprocess error when upstream has force-pushed or rebased. Now falls
back to `git reset --hard origin/main` since local changes are already
stashed.
2. **User on a feature branch / detached HEAD** — the old code would
either clobber the feature branch HEAD to point at origin/main, or
silently pull against a non-existent remote branch. Now auto-checkouts
main before pulling, with a clear warning.
3. **Fetch failures** — network or auth errors produced raw subprocess
tracebacks. Now shows user-friendly messages ("Network error",
"Authentication failed") with actionable hints.
4. **reset --hard failure** — if the fallback reset itself fails (disk
full, permissions), the old code would still attempt stash restore on
a broken working tree. Now skips restore and tells the user their
changes are safe in stash.
5. **Gateway /update stash conflicts** — non-interactive mode (Telegram
`/update`) called sys.exit(1) when stash restore had conflicts, making
the entire update report as failed even though the code update itself
succeeded. Now treats stash conflicts as non-fatal in non-interactive
mode (returns False instead of exiting).
* fix: restore stash and branch on 'already up to date' early return
The PR moved stash creation before the commit-count check (needed for
the branch-switching feature), but the 'already up to date' early return
didn't restore the stash or switch back to the original branch — leaving
the user stranded on main with changes trapped in a stash.
Now the early-return path restores the stash and checks out the original
branch when applicable.
---------
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
This commit is contained in:
@@ -2632,7 +2632,12 @@ def _restore_stashed_changes(
|
|||||||
print("Resolve conflicts manually, then run: git stash drop")
|
print("Resolve conflicts manually, then run: git stash drop")
|
||||||
|
|
||||||
print(f"Restore your changes with: git stash apply {stash_ref}")
|
print(f"Restore your changes with: git stash apply {stash_ref}")
|
||||||
sys.exit(1)
|
# 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
|
||||||
|
|
||||||
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
|
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
|
||||||
if stash_selector is None:
|
if stash_selector is None:
|
||||||
@@ -2706,30 +2711,60 @@ def cmd_update(args):
|
|||||||
|
|
||||||
# Fetch and pull
|
# Fetch and pull
|
||||||
try:
|
try:
|
||||||
print("→ Fetching updates...")
|
|
||||||
git_cmd = ["git"]
|
git_cmd = ["git"]
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
||||||
|
|
||||||
subprocess.run(git_cmd + ["fetch", "origin"], cwd=PROJECT_ROOT, check=True)
|
print("→ Fetching updates...")
|
||||||
|
fetch_result = subprocess.run(
|
||||||
# Get current branch
|
git_cmd + ["fetch", "origin"],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if fetch_result.returncode != 0:
|
||||||
|
stderr = fetch_result.stderr.strip()
|
||||||
|
if "Could not resolve host" in stderr or "unable to access" in stderr:
|
||||||
|
print("✗ Network error — cannot reach the remote repository.")
|
||||||
|
print(f" {stderr.splitlines()[0]}" if stderr else "")
|
||||||
|
elif "Authentication failed" in stderr or "could not read Username" in stderr:
|
||||||
|
print("✗ Authentication failed — check your git credentials or SSH key.")
|
||||||
|
else:
|
||||||
|
print(f"✗ Failed to fetch updates from origin.")
|
||||||
|
if stderr:
|
||||||
|
print(f" {stderr.splitlines()[0]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Get current branch (returns literal "HEAD" when detached)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
|
git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True
|
check=True,
|
||||||
)
|
)
|
||||||
branch = result.stdout.strip()
|
current_branch = result.stdout.strip()
|
||||||
|
|
||||||
# Fall back to main if the current branch doesn't exist on the remote
|
# Always update against main
|
||||||
verify = subprocess.run(
|
branch = "main"
|
||||||
git_cmd + ["rev-parse", "--verify", f"origin/{branch}"],
|
|
||||||
cwd=PROJECT_ROOT, capture_output=True, text=True,
|
# If user is on a non-main branch or detached HEAD, switch to main
|
||||||
)
|
if current_branch != "main":
|
||||||
if verify.returncode != 0:
|
label = "detached HEAD" if current_branch == "HEAD" else f"branch '{current_branch}'"
|
||||||
branch = "main"
|
print(f" ⚠ Currently on {label} — switching to main for update...")
|
||||||
|
# Stash before checkout so uncommitted work isn't lost
|
||||||
|
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||||
|
subprocess.run(
|
||||||
|
git_cmd + ["checkout", "main"],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||||
|
|
||||||
|
prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()
|
||||||
|
|
||||||
# Check if there are updates
|
# Check if there are updates
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -2737,31 +2772,69 @@ def cmd_update(args):
|
|||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True
|
check=True,
|
||||||
)
|
)
|
||||||
commit_count = int(result.stdout.strip())
|
commit_count = int(result.stdout.strip())
|
||||||
|
|
||||||
if commit_count == 0:
|
if commit_count == 0:
|
||||||
_invalidate_update_cache()
|
_invalidate_update_cache()
|
||||||
print("✓ Already up to date!")
|
# Restore stash and switch back to original branch if we moved
|
||||||
return
|
|
||||||
|
|
||||||
print(f"→ Found {commit_count} new commit(s)")
|
|
||||||
|
|
||||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
|
||||||
prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()
|
|
||||||
|
|
||||||
print("→ Pulling updates...")
|
|
||||||
try:
|
|
||||||
subprocess.run(git_cmd + ["pull", "--ff-only", "origin", branch], cwd=PROJECT_ROOT, check=True)
|
|
||||||
finally:
|
|
||||||
if auto_stash_ref is not None:
|
if auto_stash_ref is not None:
|
||||||
_restore_stashed_changes(
|
_restore_stashed_changes(
|
||||||
git_cmd,
|
git_cmd, PROJECT_ROOT, auto_stash_ref,
|
||||||
PROJECT_ROOT,
|
|
||||||
auto_stash_ref,
|
|
||||||
prompt_user=prompt_for_restore,
|
prompt_user=prompt_for_restore,
|
||||||
)
|
)
|
||||||
|
if current_branch not in ("main", "HEAD"):
|
||||||
|
subprocess.run(
|
||||||
|
git_cmd + ["checkout", current_branch],
|
||||||
|
cwd=PROJECT_ROOT, capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
print("✓ Already up to date!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"→ Found {commit_count} new commit(s)")
|
||||||
|
|
||||||
|
print("→ Pulling updates...")
|
||||||
|
update_succeeded = False
|
||||||
|
try:
|
||||||
|
pull_result = subprocess.run(
|
||||||
|
git_cmd + ["pull", "--ff-only", "origin", branch],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if pull_result.returncode != 0:
|
||||||
|
# ff-only failed — local and remote have diverged (e.g. upstream
|
||||||
|
# force-pushed or rebase). Since local changes are already
|
||||||
|
# stashed, reset to match the remote exactly.
|
||||||
|
print(" ⚠ Fast-forward not possible (history diverged), resetting to match remote...")
|
||||||
|
reset_result = subprocess.run(
|
||||||
|
git_cmd + ["reset", "--hard", f"origin/{branch}"],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if reset_result.returncode != 0:
|
||||||
|
print(f"✗ Failed to reset to origin/{branch}.")
|
||||||
|
if reset_result.stderr.strip():
|
||||||
|
print(f" {reset_result.stderr.strip()}")
|
||||||
|
print(" Try manually: git fetch origin && git reset --hard origin/main")
|
||||||
|
sys.exit(1)
|
||||||
|
update_succeeded = True
|
||||||
|
finally:
|
||||||
|
if auto_stash_ref is not None:
|
||||||
|
# Don't attempt stash restore if the code update itself failed —
|
||||||
|
# working tree is in an unknown state.
|
||||||
|
if not update_succeeded:
|
||||||
|
print(f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})")
|
||||||
|
print(f" Restore manually with: git stash apply")
|
||||||
|
else:
|
||||||
|
_restore_stashed_changes(
|
||||||
|
git_cmd,
|
||||||
|
PROJECT_ROOT,
|
||||||
|
auto_stash_ref,
|
||||||
|
prompt_user=prompt_for_restore,
|
||||||
|
)
|
||||||
|
|
||||||
_invalidate_update_cache()
|
_invalidate_update_cache()
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,8 @@ def test_restore_stashed_changes_user_declines_reset(monkeypatch, tmp_path, caps
|
|||||||
|
|
||||||
|
|
||||||
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."""
|
"""Non-interactive mode auto-resets without prompting and returns False
|
||||||
|
instead of sys.exit(1) so the update can continue (gateway /update path)."""
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
def fake_run(cmd, **kwargs):
|
||||||
@@ -282,9 +283,9 @@ def test_restore_stashed_changes_auto_resets_non_interactive(monkeypatch, tmp_pa
|
|||||||
|
|
||||||
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
|
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
|
||||||
|
|
||||||
with pytest.raises(SystemExit, match="1"):
|
result = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False)
|
||||||
hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False)
|
|
||||||
|
|
||||||
|
assert result is False
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Working tree reset 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"]]
|
reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]]
|
||||||
@@ -384,3 +385,236 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):
|
|||||||
install_cmds = [c for c in recorded if "pip" in c and "install" in c]
|
install_cmds = [c for c in recorded if "pip" in c and "install" in c]
|
||||||
assert len(install_cmds) == 1
|
assert len(install_cmds) == 1
|
||||||
assert ".[all]" in install_cmds[0]
|
assert ".[all]" in install_cmds[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ff-only fallback to reset --hard on diverged history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_update_side_effect(
|
||||||
|
current_branch="main",
|
||||||
|
commit_count="3",
|
||||||
|
ff_only_fails=False,
|
||||||
|
reset_fails=False,
|
||||||
|
fetch_fails=False,
|
||||||
|
fetch_stderr="",
|
||||||
|
):
|
||||||
|
"""Build a subprocess.run side_effect for cmd_update tests."""
|
||||||
|
recorded = []
|
||||||
|
|
||||||
|
def side_effect(cmd, **kwargs):
|
||||||
|
recorded.append(cmd)
|
||||||
|
joined = " ".join(str(c) for c in cmd)
|
||||||
|
if "fetch" in joined and "origin" in joined:
|
||||||
|
if fetch_fails:
|
||||||
|
return SimpleNamespace(stdout="", stderr=fetch_stderr, returncode=128)
|
||||||
|
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
||||||
|
if "rev-parse" in joined and "--abbrev-ref" in joined:
|
||||||
|
return SimpleNamespace(stdout=f"{current_branch}\n", stderr="", returncode=0)
|
||||||
|
if "checkout" in joined and "main" in joined:
|
||||||
|
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
||||||
|
if "rev-list" in joined:
|
||||||
|
return SimpleNamespace(stdout=f"{commit_count}\n", stderr="", returncode=0)
|
||||||
|
if "--ff-only" in joined:
|
||||||
|
if ff_only_fails:
|
||||||
|
return SimpleNamespace(
|
||||||
|
stdout="",
|
||||||
|
stderr="fatal: Not possible to fast-forward, aborting.\n",
|
||||||
|
returncode=128,
|
||||||
|
)
|
||||||
|
return SimpleNamespace(stdout="Updating abc..def\n", stderr="", returncode=0)
|
||||||
|
if "reset" in joined and "--hard" in joined:
|
||||||
|
if reset_fails:
|
||||||
|
return SimpleNamespace(stdout="", stderr="error: unable to write\n", returncode=1)
|
||||||
|
return SimpleNamespace(stdout="HEAD is now at abc123\n", stderr="", returncode=0)
|
||||||
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
return side_effect, recorded
|
||||||
|
|
||||||
|
|
||||||
|
def test_cmd_update_falls_back_to_reset_when_ff_only_fails(monkeypatch, tmp_path, capsys):
|
||||||
|
"""When --ff-only fails (diverged history), update resets to origin/{branch}."""
|
||||||
|
_setup_update_mocks(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||||
|
|
||||||
|
side_effect, recorded = _make_update_side_effect(ff_only_fails=True)
|
||||||
|
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||||
|
|
||||||
|
hermes_main.cmd_update(SimpleNamespace())
|
||||||
|
|
||||||
|
reset_calls = [c for c in recorded if "reset" in c and "--hard" in c]
|
||||||
|
assert len(reset_calls) == 1
|
||||||
|
assert reset_calls[0] == ["git", "reset", "--hard", "origin/main"]
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Fast-forward not possible" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cmd_update_no_reset_when_ff_only_succeeds(monkeypatch, tmp_path):
|
||||||
|
"""When --ff-only succeeds, no reset is attempted."""
|
||||||
|
_setup_update_mocks(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||||
|
|
||||||
|
side_effect, recorded = _make_update_side_effect()
|
||||||
|
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||||
|
|
||||||
|
hermes_main.cmd_update(SimpleNamespace())
|
||||||
|
|
||||||
|
reset_calls = [c for c in recorded if "reset" in c and "--hard" in c]
|
||||||
|
assert len(reset_calls) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-main branch → auto-checkout main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_cmd_update_switches_to_main_from_feature_branch(monkeypatch, tmp_path, capsys):
|
||||||
|
"""When on a feature branch, update checks out main before pulling."""
|
||||||
|
_setup_update_mocks(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||||
|
|
||||||
|
side_effect, recorded = _make_update_side_effect(current_branch="fix/something")
|
||||||
|
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||||
|
|
||||||
|
hermes_main.cmd_update(SimpleNamespace())
|
||||||
|
|
||||||
|
checkout_calls = [c for c in recorded if "checkout" in c and "main" in c]
|
||||||
|
assert len(checkout_calls) == 1
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "fix/something" in out
|
||||||
|
assert "switching to main" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cmd_update_switches_to_main_from_detached_head(monkeypatch, tmp_path, capsys):
|
||||||
|
"""When in detached HEAD state, update checks out main before pulling."""
|
||||||
|
_setup_update_mocks(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||||
|
|
||||||
|
side_effect, recorded = _make_update_side_effect(current_branch="HEAD")
|
||||||
|
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||||
|
|
||||||
|
hermes_main.cmd_update(SimpleNamespace())
|
||||||
|
|
||||||
|
checkout_calls = [c for c in recorded if "checkout" in c and "main" in c]
|
||||||
|
assert len(checkout_calls) == 1
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "detached HEAD" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cmd_update_restores_stash_and_branch_when_already_up_to_date(monkeypatch, tmp_path, capsys):
|
||||||
|
"""When on a feature branch with no updates, stash is restored and branch switched back."""
|
||||||
|
_setup_update_mocks(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||||
|
|
||||||
|
# Enable stash so it returns a ref
|
||||||
|
monkeypatch.setattr(
|
||||||
|
hermes_main, "_stash_local_changes_if_needed",
|
||||||
|
lambda *a, **kw: "abc123deadbeef",
|
||||||
|
)
|
||||||
|
restore_calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
hermes_main, "_restore_stashed_changes",
|
||||||
|
lambda *a, **kw: restore_calls.append(1) or True,
|
||||||
|
)
|
||||||
|
|
||||||
|
side_effect, recorded = _make_update_side_effect(
|
||||||
|
current_branch="fix/something", commit_count="0",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||||
|
|
||||||
|
hermes_main.cmd_update(SimpleNamespace())
|
||||||
|
|
||||||
|
# Stash should have been restored
|
||||||
|
assert len(restore_calls) == 1
|
||||||
|
|
||||||
|
# Should have checked out back to the original branch
|
||||||
|
checkout_back = [c for c in recorded if "checkout" in c and "fix/something" in c]
|
||||||
|
assert len(checkout_back) == 1
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Already up to date" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path):
|
||||||
|
"""When already on main, no checkout is needed."""
|
||||||
|
_setup_update_mocks(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
||||||
|
|
||||||
|
side_effect, recorded = _make_update_side_effect()
|
||||||
|
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||||
|
|
||||||
|
hermes_main.cmd_update(SimpleNamespace())
|
||||||
|
|
||||||
|
checkout_calls = [c for c in recorded if "checkout" in c]
|
||||||
|
assert len(checkout_calls) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fetch failure — friendly error messages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_cmd_update_network_error_shows_friendly_message(monkeypatch, tmp_path, capsys):
|
||||||
|
"""Network failures during fetch show a user-friendly message."""
|
||||||
|
_setup_update_mocks(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
side_effect, _ = _make_update_side_effect(
|
||||||
|
fetch_fails=True,
|
||||||
|
fetch_stderr="fatal: unable to access 'https://...': Could not resolve host: github.com",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
hermes_main.cmd_update(SimpleNamespace())
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Network error" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_cmd_update_auth_error_shows_friendly_message(monkeypatch, tmp_path, capsys):
|
||||||
|
"""Auth failures during fetch show a user-friendly message."""
|
||||||
|
_setup_update_mocks(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
side_effect, _ = _make_update_side_effect(
|
||||||
|
fetch_fails=True,
|
||||||
|
fetch_stderr="fatal: Authentication failed for 'https://...'",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
hermes_main.cmd_update(SimpleNamespace())
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Authentication failed" in out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# reset --hard failure — don't attempt stash restore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path, capsys):
|
||||||
|
"""When reset --hard fails, stash restore is skipped with a helpful message."""
|
||||||
|
_setup_update_mocks(monkeypatch, tmp_path)
|
||||||
|
# Re-enable stash so it actually returns a ref
|
||||||
|
monkeypatch.setattr(
|
||||||
|
hermes_main, "_stash_local_changes_if_needed",
|
||||||
|
lambda *a, **kw: "abc123deadbeef",
|
||||||
|
)
|
||||||
|
restore_calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
hermes_main, "_restore_stashed_changes",
|
||||||
|
lambda *a, **kw: restore_calls.append(1) or True,
|
||||||
|
)
|
||||||
|
|
||||||
|
side_effect, _ = _make_update_side_effect(ff_only_fails=True, reset_fails=True)
|
||||||
|
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
hermes_main.cmd_update(SimpleNamespace())
|
||||||
|
|
||||||
|
# Stash restore should NOT have been called
|
||||||
|
assert len(restore_calls) == 0
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "preserved in stash" in out
|
||||||
|
|||||||
Reference in New Issue
Block a user