"""Autostash handling for ``hermes update``: stash before the pull, restore/park/discard afterwards, warn about orphans.

Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.<name>`` still resolves/monkeypatches.
Origin helpers are imported lazily per function (no cycle; test patches on the origin stay effective).
"""

import logging
import re
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional

# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.update_cmd")

#: Autostash subject contract: this prefix + UTC YYYYMMDD-HHMMSS stamp
#: (producer _stash_local_changes_if_needed, consumer _warn_orphaned_update_autostashes).
_AUTOSTASH_NAME_PREFIX = "hermes-update-autostash-"

#: Age past which a leftover autostash is called out. Younger entries are normal
#: (recent --keep-stash park); older ones are almost always forgotten.
# Entries younger than this are normal (a parked stash from : the desktop updater's --keep-stash run minutes
# ago); older ones are almost : always forgotten (#63717 problem 6: an orphan persisted 9+ days unnoticed).
_AUTOSTASH_WARN_AGE_DAYS = 7

_STASH_LEFT_IN_PLACE = "  The stash was left in place. You can remove it manually after checking the result."

#: This run's autostash until it is settled: (stash ref, stashed path count). Set when the
#: update stashes local patches; cleared when they are restored, discarded or parked because
#: the user asked for it, or once a failure verdict already named them. While it is set, no
#: outcome may claim the update completed (#122557).
_pending_autostash: Optional[tuple[str, int]] = None


def _git_quiet(git_cmd: list[str], args: list[str], cwd: Path, **kwargs):
    """``subprocess.run`` of a git command with captured output; None when git cannot run."""
    try:
        return subprocess.run(git_cmd + args, cwd=cwd, capture_output=True, **kwargs)
    except (OSError, subprocess.SubprocessError):
        return None


def _intent_to_add_paths(porcelain_z: str) -> tuple[str, ...]:
    """Paths recorded with ``git add -N`` (intent-to-add), read from NUL-separated porcelain output.

    Such an entry carries the empty blob with zeroed stat data, so it is never "uptodate" and
    ``git stash push`` rejects the whole stash with ``Entry 'X' not uptodate. Cannot merge.`` - a dead
    end for the user, because that is the state an editor leaves behind when it shows a new file in
    diffs. Porcelain marks it ``" A"`` (in the worktree, absent from the index); a real staged add is
    ``"A "`` and an untracked file ``"??"``. Rename records carry a second, prefix-less field, which
    the leading ``" A"`` test cannot match.
    """
    return tuple(
        record[3:] for record in porcelain_z.split("\0")
        if len(record) > 3 and record[0] == " " and record[1] == "A"
    )


def _git_paths_z(git_cmd: list[str], args: list[str], cwd: Path):
    """NUL-separated path listing as a set, or None when git failed (surrogateescape keeps odd filenames)."""
    result = _git_quiet(git_cmd, args, cwd, text=True, encoding="utf-8", errors="surrogateescape")
    if result is None or result.returncode != 0:
        return None
    return {path for path in result.stdout.split("\0") if path}


def _reset_hard(git_cmd: list[str], cwd: Path) -> None:
    subprocess.run(git_cmd + ["reset", "--hard", "HEAD"], cwd=cwd, capture_output=True)


def _print_nonempty(text: str, prefix: str = "") -> None:
    if text.strip():
        print(f"{prefix}{text.strip()}")


def _print_first_line(text: str) -> None:
    if text.strip():
        print(f"  {text.strip().splitlines()[0]}")


def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
    global _pending_autostash
    from hermes_cli.update_cmd_git import _git_run
    _pending_autostash = None
    status = _git_run(git_cmd, ["status", "--porcelain", "-z"], cwd, check=True)
    if not status.stdout.strip():
        return None
    # Unmerged index entries (interrupted merge/rebase) make `git stash` fail with
    # "needs merge"; `git reset` drops only the index conflict state, not the tree.
    if _git_run(git_cmd, ["ls-files", "--unmerged"], cwd).stdout.strip():
        print("→ Clearing unmerged index entries from a previous conflict...")
        subprocess.run(git_cmd + ["reset"], cwd=cwd, capture_output=True)

    # Intent-to-add entries are the other index state `git stash push` refuses (see
    # _intent_to_add_paths). Promoting them to real staged adds keeps their content and is lossless
    # for the user's work; after the restore they come back as staged additions, which is the closest
    # `git stash` can represent.
    intent_to_add = _intent_to_add_paths(status.stdout)
    if intent_to_add:
        print(f"→ Making {len(intent_to_add)} intent-to-add file(s) stashable...")
        add = _git_run(git_cmd, ["add", "--", *intent_to_add], cwd)
        if add.returncode != 0:
            _print_nonempty(add.stderr)

    stash_name = datetime.now(timezone.utc).strftime(f"{_AUTOSTASH_NAME_PREFIX}%Y%m%d-%H%M%S")
    print("→ Local changes detected — stashing before update...")
    prev_stash = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd).stdout.strip()
    push = _git_run(git_cmd, ["stash", "push", "--include-untracked", "-m", stash_name], cwd)
    _print_nonempty(push.stdout)
    stash_probe = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd)
    stash_ref = stash_probe.stdout.strip()
    stash_created = stash_probe.returncode == 0 and bool(stash_ref) and stash_ref != prev_stash
    if push.returncode != 0:
        if not stash_created:
            # No entry created: changes NOT saved — bail before touching HEAD.
            print("✗ Could not stash local changes — update aborted.")
            _print_first_line(push.stderr)
            print("  Commit, stash, or clean up your local changes manually, then re-run `hermes update`.")
            print("  (An index entry from `git add -N` is the usual cause of this error; `git add` the")
            print("   paths it names, or `git reset` them, and the update will proceed.)")
            raise subprocess.CalledProcessError(push.returncode, push.args, output=push.stdout, stderr=push.stderr)
        # Non-zero but entry created: push saved everything yet couldn't delete some untracked files
        # (e.g. root-owned dir). Not a failure — continue.
        _print_nonempty(push.stderr)
        print("  ⚠ Some untracked files could not be removed from the working tree (permission denied).")
        print("    They were still saved to the stash and were left in place — the update will continue.")
        # A partially-failed push also skips cleanup of TRACKED modifications; they'd break the following
        # pull. Safe to reset: all is in the stash.
        _reset_hard(git_cmd, cwd)
    _pending_autostash = (
        stash_ref, len([entry for entry in status.stdout.split("\0") if entry]))
    return stash_ref


def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Optional[str]:
    """Selector for the stash entry whose commit is *stash_ref*, as the bare index ``N``
    (git accepts it wherever ``stash@{N}`` is valid). Never ``stash@{N}`` itself: on native
    Windows the MSYS runtime strips the braces from git.exe's argv, so ``stash@{0}`` reaches git
    as ``stash@0`` and the drop fails (#87542)."""
    from hermes_cli.update_cmd_git import _git_run
    stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %H"], cwd, check=True)
    for line in stash_list.stdout.splitlines():
        selector, _, commit = line.partition(" ")
        if commit.strip() == stash_ref:
            match = re.fullmatch(r"stash@\{(\d+)\}", selector.strip())
            return match.group(1) if match else selector.strip()
    return None


def _clear_pending_autostash() -> None:
    """Forget this run's unsettled autostash after it was already reported (#122557)."""
    global _pending_autostash
    _pending_autostash = None


def _unrestored_autostash_notice() -> Optional[str]:
    """What to tell the user while this run's autostash is unsettled (#122557), else ``None``.

    Used as the completion line (it is not a success line, so the update ends partial and
    exits non-zero) and printed by failure verdicts, which otherwise never name the stash.
    """
    if _pending_autostash is None:
        return None
    stash_ref, file_count = _pending_autostash
    return (f"⚠ hermes update stashed {file_count} local modification(s) and did NOT restore them.\n"
            f"  Stash ref: {stash_ref}\n"
            f"  Review with: git stash show --stat {stash_ref}\n"
            f"  Re-apply with: git stash show -p {stash_ref} | git apply --3way")


def _warn_orphaned_update_autostashes(git_cmd: list[str], cwd: Path) -> int:
    """Print a notice for update autostashes older than the warn threshold; return the count (0 on any git failure).

    Autostashes legitimately outlive a run (--keep-stash, failed restore) but nothing re-surfaces them.
    Deliberately NOT a GC: a stash may be the only copy of the user's work, so Hermes never drops one.

    Autostash entries legitimately outlive an update run (``--keep-stash`` parks them; a conflicted or
    failed restore preserves them for safety), but nothing ever re-surfaces them afterwards — they sit in
    ``git stash`` invisibly for weeks (#63717 problem 6). This prints a short notice naming the stale
    entries with recovery/cleanup guidance.
    """
    from hermes_cli.update_cmd_git import _git_run
    try:
        stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %s"], cwd)
        if stash_list.returncode != 0:
            return 0
        cutoff = datetime.now(timezone.utc) - timedelta(days=_AUTOSTASH_WARN_AGE_DAYS)
        stale: list[tuple[str, str]] = []
        for line in stash_list.stdout.splitlines():
            selector, _, subject = line.strip().partition(" ")
            pos = subject.find(_AUTOSTASH_NAME_PREFIX)
            if pos < 0:
                continue
            stamp = subject[pos + len(_AUTOSTASH_NAME_PREFIX):][:15]  # "YYYYMMDD-HHMMSS"
            try:
                stash_time = datetime.strptime(stamp, "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc)
            except ValueError:
                continue  # age unknown — leave it alone rather than guess
            if stash_time < cutoff:
                stale.append((selector, stamp))
        if not stale:
            return 0
        print()
        print(
            f"⚠ {len(stale)} leftover update autostash entr"
            f"{'y is' if len(stale) == 1 else 'ies are'} more than "
            f"{_AUTOSTASH_WARN_AGE_DAYS} days old:"
        )
        for selector, stamp in stale:
            print(f"    {selector}  ({_AUTOSTASH_NAME_PREFIX}{stamp})")
        print("  These hold local changes stashed by earlier updates and never")
        print("  restored. Review with: git stash show -p <entry>")
        print("  Restore with: git stash apply <entry>   Discard with: git stash drop <entry>")
        return len(stale)
    except Exception as exc:
        logger.debug("Autostash age check failed: %s", exc)
        return 0


def _record_stash_disposition(outcome: str, stash_ref: str, detail: str = "", *, chosen: bool = False) -> None:
    """Note the autostash disposition in the update receipt so a parked stash is visible
    to automation reading receipts instead of stdout (#115363: an update that ended with
    local changes parked in the stash reported a bare success with no trace of them).
    A park the user did not ask for (``chosen``) keeps the stash unsettled (#122557)."""
    global _pending_autostash
    if outcome != "parked" or chosen:
        _pending_autostash = None
    from hermes_cli.update_receipt import record_step
    record_step(
        "local_changes_stash",
        outcome != "parked",
        f"{outcome}: {stash_ref}" + (f" ({detail})" if detail else ""),
    )


def _print_stash_cleanup_guidance(stash_ref: str, stash_selector: Optional[str] = None) -> None:
    print("  Check `git status` first so you don't accidentally reapply the same change twice.")
    print("  Find the saved entry with: git stash list --format='%gd %H %s'")
    if stash_selector:
        print(f"  Remove it with: git stash drop {stash_selector}")
    else:
        print(f"  Look for commit {stash_ref}, then drop it by index with: git stash drop <N>")


def _stash_apply_failed_only_on_existing_untracked(stderr: str) -> bool:
    """True when a ``git stash apply`` failure is ONLY about untracked files that already exist in the tree.

    Tail of the permission-denied class: push swept undeletable files into the stash but couldn't remove them;
    apply restores tracked changes, then refuses to overwrite those files and exits non-zero though nothing was lost.
    Any other error line (e.g. ``would be overwritten by merge``) means the tracked apply failed -> False.
    """
    saw_untracked_error = False
    for ln in (ln.strip() for ln in (stderr or "").splitlines() if ln.strip()):
        if "already exists, no checkout" in ln or "could not restore untracked files from stash" in ln:
            saw_untracked_error = True
        elif not ln.startswith(("warning:", "hint:")):
            return False
    return saw_untracked_error


def _untracked_files_replaced_by_update(git_cmd: list[str], cwd: Path, stash_ref: str, stderr: str) -> list[str]:
    """Paths ``git stash apply`` refused ("<path> already exists, no checkout") because the update
    now tracks its own file there (``HEAD:<path>``) and the tree does not hold the stash's untracked
    copy (``<stash>^3``). An untracked occupant HEAD does not track is the #70127 file that could
    not be deleted at stash time, whatever its content now; it is never the update's."""
    replaced = []
    suffix = " already exists, no checkout"
    for ln in (stderr or "").splitlines():
        ln = ln.strip()
        if not ln.endswith(suffix):
            continue
        rel = ln[: -len(suffix)]
        tracked = subprocess.run([*git_cmd, "cat-file", "-e", f"HEAD:{rel}"], cwd=cwd, capture_output=True, check=False)
        if tracked.returncode != 0:
            continue
        stashed = subprocess.run(
            [*git_cmd, "show", f"{stash_ref}^3:{rel}"], cwd=cwd, capture_output=True, check=False,
        )
        try:
            current = (Path(cwd) / rel).read_bytes()
        except OSError:
            current = None
        if stashed.returncode != 0 or current != stashed.stdout:
            replaced.append(rel)
    return replaced


def _park_stashed_changes(stash_ref: str) -> None:
    """Leave a pre-update autostash parked (``--keep-stash``, the desktop updater's mode): local source
    edits must never be silently re-applied onto updated code; the entry stays in ``git stash``."""
    print()
    print("ℹ️  Local changes were stashed before updating and were NOT re-applied (--keep-stash).")
    print(f"  Stash ref: {stash_ref}")
    print(f"  Restore manually with: git stash apply {stash_ref}")
    _record_stash_disposition("parked", stash_ref, "--keep-stash", chosen=True)


def _git_untracked_paths(git_cmd: list[str], cwd: Path) -> set[str] | None:
    """Return untracked paths, or ``None`` when Git cannot enumerate them."""
    paths = _git_paths_z(git_cmd, ["ls-files", "--others", "--exclude-standard", "-z"], cwd)
    if paths is None:
        print("  ⚠ Could not enumerate untracked files while validating the restored stash.")
    return paths


def _restored_python_paths(git_cmd: list[str], cwd: Path) -> tuple[str, ...] | None:
    """Restored ``.py`` paths changed from ``HEAD``; deliberately Python-only (entry scripts stay outside the health check)."""
    from hermes_cli.update_cmd import _git_untracked_paths
    paths = _git_paths_z(git_cmd, ["diff", "--name-only", "-z", "HEAD", "--", "*.py"], cwd)
    if paths is None:
        print("  ⚠ Could not enumerate tracked Python files restored from the stash.")
        return None
    untracked = _git_untracked_paths(git_cmd, cwd)
    if untracked is None:
        return None
    paths.update(path for path in untracked if path.endswith(".py"))
    return tuple(sorted(paths))


def _reject_unsafe_stash_restore(
    git_cmd: list[str], cwd: Path, stash_ref: str, preexisting_untracked: set[str], failing_target: str,
    detail: str | None,
) -> None:
    """Restore the clean updated tree, preserve the stash, and abort the update."""
    from hermes_cli.update_cmd import _git_untracked_paths
    print()
    print("✗ Restored local changes made the Hermes agent unexecutable.")
    print(f"  Health check failed: {failing_target}")
    if detail:
        for line in str(detail).splitlines()[:6]:
            print(f"    {line}")

    def _ok(result) -> bool:
        return result is not None and result.returncode == 0

    current_untracked = _git_untracked_paths(git_cmd, cwd)
    restored_untracked = current_untracked - preexisting_untracked if current_untracked is not None else set()
    reset = _git_quiet(git_cmd, ["reset", "--hard", "HEAD"], cwd)
    clean = _git_quiet(git_cmd, ["clean", "-fd", "--", *sorted(restored_untracked)], cwd) if restored_untracked else None
    cleanup_ok = current_untracked is not None and _ok(reset) and (not restored_untracked or _ok(clean))
    if cleanup_ok:
        cleanup_ok = _ok(_git_quiet(git_cmd, ["diff", "--quiet", "HEAD", "--"], cwd))
    if cleanup_ok:
        print("  The clean updated tree has been restored; the gateway was not restarted.")
    else:
        print("  ⚠ The clean updated tree could not be fully restored automatically.")
        print("    Inspect `git status` and run `git reset --hard HEAD` before retrying.")
    print("  Platform connectivity alone does not mean the agent can execute turns.")
    print(f"  Your local changes remain preserved in stash: {stash_ref}")
    print(f"  Inspect them with: git stash show --stat {stash_ref}")
    print(f"  Restore manually after fixing them: git stash apply {stash_ref}")
    _clear_pending_autostash()  # named right above, and the update fails
    raise SystemExit(1)


def _confirm_restore(stash_ref: str, input_fn) -> bool:
    """Interactive gate; a remote ``input_fn`` defaults to No (``[y/N]``), the local prompt to Yes."""
    remote_prompt = input_fn is not None
    prompt_suffix = "[y/N]" if remote_prompt else "[Y/n]"
    print()
    print("⚠ Local changes were stashed before updating.")
    print("  Restoring them may reapply local customizations onto the updated codebase.")
    print("  Review the result afterward if Hermes behaves unexpectedly.")
    print(f"Restore local changes now? {prompt_suffix}")
    if remote_prompt:
        response = input_fn(f"Restore local changes now? {prompt_suffix}", "n")
    else:
        try:
            response = input().strip().lower()
        except (EOFError, UnicodeDecodeError):
            response = "n"  # closed stdin/encoding error must not crash mid-restore
    if response in {"y", "yes"} or (not remote_prompt and response == ""):
        return True
    print("Skipped restoring local changes.")
    print("Your changes are still preserved in git stash.")
    print(f"Restore manually with: git stash apply {stash_ref}")
    return False


def _apply_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> list[str] | None:
    """``git stash apply``; the untracked paths the update replaced (empty on a full restore), or
    None (tree reset, stash kept) on conflicts or any failure other than the already-exists class."""
    from hermes_cli.update_cmd_git import _git_run
    print("→ Restoring local changes...")
    restore = _git_run(git_cmd, ["stash", "apply", stash_ref], cwd)
    unmerged = _git_run(git_cmd, ["diff", "--name-only", "--diff-filter=U"], cwd)  # conflicts can exist even on rc 0
    conflicted_files = unmerged.stdout.strip()
    if restore.returncode == 0 and not conflicted_files:
        return []
    if not conflicted_files and _stash_apply_failed_only_on_existing_untracked(restore.stderr):
        # Tracked changes applied; git refused to overwrite untracked files that already exist. That is
        # harmless only when the occupant IS the stashed copy (undeletable at stash time, #70127). When
        # the update added its own file at that path, the stash is the only copy of the user's file.
        replaced = _untracked_files_replaced_by_update(git_cmd, cwd, stash_ref, restore.stderr)
        if not replaced:
            print("  ⚠ Some stashed untracked files already exist in the working tree and were kept as-is.")
        return replaced
    print("✗ Update pulled new code, but restoring local changes hit conflicts.")
    _print_nonempty(restore.stdout)
    _print_nonempty(restore.stderr)
    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}")
    _reset_hard(git_cmd, cwd)  # conflict markers make hermes unrunnable; changes stay in the stash
    print("Working tree reset to clean state.")
    print(f"Restore your changes later with: git stash apply {stash_ref}")
    _record_stash_disposition("parked", stash_ref, "restore hit conflicts")
    return None  # code update succeeded; cmd_update continues (deps, skills, gateway)


def _drop_restored_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> None:
    from hermes_cli.update_cmd_git import _git_run
    stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
    if stash_selector is None:
        print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.")
        print(_STASH_LEFT_IN_PLACE)
        _print_stash_cleanup_guidance(stash_ref)
        return
    drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd)
    if drop.returncode != 0:
        print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.")
        _print_nonempty(drop.stdout)
        _print_nonempty(drop.stderr)
        print(_STASH_LEFT_IN_PLACE)
        _print_stash_cleanup_guidance(stash_ref, stash_selector)


def _restore_stashed_changes(
    git_cmd: list[str], cwd: Path, stash_ref: str, prompt_user: bool = False, input_fn=None,
) -> bool:
    from hermes_cli.update_cmd import _critical_module_import_failures, _git_untracked_paths, _restored_python_paths, _validate_python_files_syntax
    if prompt_user and not _confirm_restore(stash_ref, input_fn):
        _record_stash_disposition("parked", stash_ref, "restore declined", chosen=True)
        return False
    preexisting_untracked = _git_untracked_paths(git_cmd, cwd)
    if preexisting_untracked is None:
        print("  The stash was not restored because its cleanup baseline is unknown.")
        print(f"  Restore manually with: git stash apply {stash_ref}")
        _record_stash_disposition("parked", stash_ref, "untracked baseline unknown")
        return False
    clean_import_failures = _critical_module_import_failures(cwd, report_runtime_errors=True)
    replaced = _apply_stash(git_cmd, cwd, stash_ref)
    if replaced is None:
        return False  # disposition already recorded inside _apply_stash

    def reject(failing_target: str, detail) -> None:
        _reject_unsafe_stash_restore(git_cmd, cwd, stash_ref, preexisting_untracked, failing_target, detail)

    restored_python = _restored_python_paths(git_cmd, cwd)
    if restored_python is None:
        reject("restored Python source discovery", "could not determine which restored Python files require validation")
    syntax_ok, failing_path, syntax_error = _validate_python_files_syntax(cwd, restored_python)
    if not syntax_ok:
        reject(failing_path or "restored Python source", syntax_error)
    for module, error in _critical_module_import_failures(cwd, report_runtime_errors=True).items():
        if clean_import_failures.get(module) != error:
            reject(f"agent import {module or 'unknown'}", error[1])
            break
    if replaced:
        # The restored tree is healthy, but dropping the stash would lose the user's copies.
        print(f"⚠ The update added {len(replaced)} file(s) where you had untracked files of the same name:")
        for path in replaced[:10]:
            print(f"    {path}")
        print("  The updated files are in place; your versions are kept in the stash, which was NOT dropped.")
        print(f"  Stash ref: {stash_ref}")
        print(f"  Recover a file with: git show {stash_ref}^3:<path> > <path>.mine")
        _record_stash_disposition("parked", stash_ref, f"untracked files replaced by the update: {', '.join(replaced[:10])}")
        return False
    _drop_restored_stash(git_cmd, cwd, stash_ref)
    _record_stash_disposition("restored", stash_ref)
    print("⚠ Local changes were restored on top of the updated codebase.")
    print("  Review `git diff` / `git status` if Hermes behaves unexpectedly.")
    return True


def _discard_stashed_changes(git_cmd: list[str], cwd: Path, stash_ref: str) -> bool:
    """Drop a pre-update stash without applying (non-interactive ``updates.non_interactive_local_changes: discard``).

    Unlike reset --hard + clean -fd this touches only what was stashed; ignored paths are never affected.
    Returns True if dropped, False on git failure (stash left in place).
    """
    from hermes_cli.update_cmd_git import _git_run
    stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
    if stash_selector is None:
        print(
            "⚠ Configured to discard local changes on non-interactive update, "
            "but Hermes couldn't find the stash entry to drop."
        )
        _print_stash_cleanup_guidance(stash_ref)
        return False
    drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd)
    if drop.returncode != 0:
        print("⚠ Configured to discard local changes, but Hermes couldn't drop the saved stash entry.")
        _print_first_line(drop.stderr)
        _print_stash_cleanup_guidance(stash_ref, stash_selector)
        _record_stash_disposition("parked", stash_ref, "configured discard failed")
        return False
    print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).")
    _record_stash_disposition("discarded", stash_ref)
    return True
