Files
hermes-agent/hermes_cli/worktree_exit.py
T
Teknium ab1c29d8bb fix: worktree sweep keeps any tree holding work an archive cannot restore
The second review found trees deleted with bytes or commits that were in
neither git nor the archive: untracked files over archive_max_file_mb
(skipped from the snapshot, tree still removed), gitignored embedded
repositories (ls-files --exclude-standard never lists them) and every
ignored file (.env edits, *.secret, info/exclude notes). The classic
pruner had three older gaps of the same class: status.showUntrackedFiles=no
made new files invisible, skip-worktree/assume-unchanged edits read clean,
and commits held only by refs/worktree/* or refs/bisect/* died with the
tree's admin dir.

Rule now enforced at one decision point (worktree_retention.removal_blocker)
used by archive_preflight, the post-snapshot removal boundary in
archive_then_remove (sweep, exit Archive, subagent finalize), the classic
clean-tree pruner, the attended `hermes worktree prune` and the clean exit
path: a tree is removed only when everything irreproducible is in git or the
archive. Kept: keep-large-untracked (archive ref stays), keep-unarchivable
(embedded repo, ignored or not), keep-worktree-refs, keep-ignored-work
(ignored files outside a regenerable-dir allowlist that are not
byte-identical to the main checkout's copy, so .worktreeinclude .env copies
never block). Exit Archive and the subagent note name the reason. Every
dirty check forces --untracked-files=normal and counts flagged-index edits.
The ignored-path walk skips dependency stores without descending and is
capped per tree (unverifiable -> keep).
2026-10-17 21:49:14 -07:00

120 lines
5.3 KiB
Python

"""The keep / archive / discard question when an interactive ``hermes -w`` session exits with a
dirty or unpushed worktree.
Asked once, in the foreground, after the UI released the terminal: by the classic CLI in
``_tui_shutdown`` (before ``_run_cleanup`` arms the exit watchdog) and by the TUI launcher after
the Ink child exits. The answer is applied right there, synchronously, before any exit watchdog
or teardown runs: a large archive is never cut short, and a failed one keeps the tree. Only a real interactive session
asks: ``-q``/``-z``/cron/gateway never reach these call sites, a missing TTY or a signal-driven
shutdown skips the question, and every skip means Keep (the sweep archives the tree later).
"""
from __future__ import annotations
import logging
import os
import sys
from typing import Optional
from agent.i18n import t
logger = logging.getLogger(__name__)
KEEP, ARCHIVE, DISCARD = "keep", "archive", "discard"
def _has_tty() -> bool:
try:
return sys.stdin.isatty() and sys.stdout.isatty()
except (AttributeError, ValueError):
return False
def worktree_state(info: dict) -> Optional[str]:
"""``"dirty"``, ``"unpushed"`` or None (clean and pushed / gone)."""
from hermes_cli.worktree_ops import _worktree_has_unpushed_commits, _worktree_is_dirty
path, repo_root = info.get("path", ""), info.get("repo_root", "")
if not path or not os.path.isdir(path):
return None
if _worktree_is_dirty(path, repo_root, timeout=10):
return "dirty"
if _worktree_has_unpushed_commits(path, timeout=10):
return "unpushed"
return None
def _still_in_use(path: str, grace_s: float = 5.0) -> bool:
"""Whether a live process (other than this one) still works in *path* after a short grace:
the TUI's own backend (``tui_gateway``) takes a moment to exit after the Ink UI closes."""
import time
from hermes_cli.worktree_sweep import live_process_pids
deadline = time.monotonic() + grace_s
while live_process_pids([path], exclude=(os.getpid(),)).get(path):
if time.monotonic() >= deadline:
return True
time.sleep(0.25)
return False
def ask_exit_choice(info: Optional[dict]) -> str:
"""Ask what to do with a dirty/unpushed tree; records the answer in ``info["exit_choice"]``."""
if not info or not _has_tty():
return KEEP
state = worktree_state(info)
if state is None:
return KEEP
if _still_in_use(info["path"]):
return KEEP # something still runs in there; never offer to delete it
from hermes_cli.curses_ui import curses_radiolist
title = t(f"cli.worktree.exit_question_{state}", path=info["path"])
items = [t("cli.worktree.exit_choice_keep"), t("cli.worktree.exit_choice_archive"),
t("cli.worktree.exit_choice_discard")]
try: # Esc / Ctrl-C / a curses failure all resolve to Keep (index 0)
idx = curses_radiolist(title, items, selected=0, cancel_returns=0)
except Exception:
logger.debug("worktree exit question failed; keeping the tree", exc_info=True)
idx = None
choice = {1: ARCHIVE, 2: DISCARD}.get(idx, KEEP) if isinstance(idx, int) else KEEP
info["exit_choice"] = choice
return choice
def apply_exit_choice(info: dict, echo) -> bool:
"""Carry out an Archive/Discard answer once -> True when the tree was handled (removed or a
message printed). Keep (or no answer) -> False: the caller's default keep path runs."""
choice = info.pop("exit_choice", None)
if choice not in (ARCHIVE, DISCARD):
return False
path, repo_root, branch = info["path"], info["repo_root"], info.get("branch")
if choice == ARCHIVE:
import subprocess
from hermes_cli.worktree_archive import ArchiveError
from hermes_cli.worktree_sweep import archive_then_remove, sweep_settings
echo(t("cli.worktree.archiving", path=path))
try:
ref, _kept, removed, why = archive_then_remove(
repo_root, path, reason="session exit", unlock=True, exclude_pids=(os.getpid(),),
max_untracked_mb=sweep_settings()["archive_max_file_mb"])
except (ArchiveError, OSError, subprocess.SubprocessError, KeyboardInterrupt) as exc:
echo(f"\033[31m{t('cli.worktree.archive_failed', path=path, error=str(exc) or 'interrupted')}\033[0m")
return True
name = ref.rsplit("/", 1)[-1]
if removed:
echo(f"\033[32m{t('cli.worktree.archived', path=path, ref=ref)}\033[0m")
else:
echo(f"\033[33m{t('cli.worktree.archived_kept', path=path, ref=ref, reason=why or '?')}\033[0m")
echo(f" {t('cli.worktree.restore_hint', name=name)}")
return True
from hermes_cli._subprocess_compat import noninteractive_git_env
from hermes_cli.worktree_ops import _git_quiet, release_lsp_clients
release_lsp_clients(path)
_git_quiet(["worktree", "unlock", path], repo_root, log="git worktree unlock failed (non-fatal)")
_git_quiet(["worktree", "remove", path, "--force"], repo_root, timeout=60, log="Failed to remove worktree")
if branch and not os.path.isdir(path):
_git_quiet(["branch", "-D", branch], repo_root, log=f"Failed to delete branch {branch}",
env=noninteractive_git_env())
echo(f"\033[33m{t('cli.worktree.discarded', path=path)}\033[0m")
return True