mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
Two paths printed "Created worktree but could not enter it" when that was not the whole story: - The gateway refused the move (e.g. session_busy). Nothing moved, but the fresh tree stayed on disk, unlisted by /worktree, until quit removed it. It is now removed right away (an earlier active tree stays active), and the refusal carries the check that fired. - The gateway accepted the move but this process could not chdir into the tree. The session's tools do run in the tree, so the tree is now the active one, the success lines print, and the warning is the existing "could not enter session's working directory" line. The step lives in a new hermes_cli/cli_commands_worktree.py, since cli_commands_mixin.py may not grow. Refs #134889 (CLI-15)
221 lines
8.4 KiB
Python
221 lines
8.4 KiB
Python
"""Tests for the CLI ``/worktree`` command handler.
|
|
|
|
``/worktree`` (Copilot CLI-inspired) inspects or creates isolated git
|
|
worktrees mid-session. These drive the mixin handler against real git repos:
|
|
status/list output, ``new`` creation with cwd + TERMINAL_CWD retargeting,
|
|
named-tree collision refusal, and graceful non-repo degradation.
|
|
"""
|
|
|
|
import contextlib
|
|
import io
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from agent.i18n import t
|
|
|
|
import cli as cli_mod
|
|
from hermes_cli.cli_commands_mixin import CLICommandsMixin
|
|
|
|
requires_git = pytest.mark.skipif(
|
|
shutil.which("git") is None, reason="git required"
|
|
)
|
|
|
|
class _Stub(CLICommandsMixin):
|
|
def __init__(self):
|
|
self.agent = None
|
|
|
|
def _run(stub, command):
|
|
"""Capture handler output.
|
|
|
|
Worktree messages route through ``cli._cprint`` (the ANSI-aware
|
|
prompt_toolkit renderer) since the garbled-escape fix; its output binds
|
|
to the prompt_toolkit session output object, not ``sys.stdout``, so
|
|
``redirect_stdout`` alone cannot capture it. Mirror ``_cprint`` into the
|
|
same buffer to keep asserting the user-visible text.
|
|
"""
|
|
import re
|
|
from unittest.mock import patch as _patch
|
|
|
|
buf = io.StringIO()
|
|
ansi = re.compile(r"\x1b\[[0-9;]*m")
|
|
|
|
def _fake_cprint(text):
|
|
buf.write(ansi.sub("", str(text)) + "\n")
|
|
|
|
with contextlib.redirect_stdout(buf), _patch.object(cli_mod, "_cprint", _fake_cprint):
|
|
stub._handle_worktree_command(command)
|
|
return buf.getvalue()
|
|
|
|
def _git(repo, *args):
|
|
subprocess.run(
|
|
["git", *args], cwd=repo, check=True, capture_output=True,
|
|
env={"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
|
"HOME": str(repo), "PATH": os.environ["PATH"]},
|
|
)
|
|
|
|
@pytest.fixture()
|
|
def repo(tmp_path, monkeypatch):
|
|
repo = tmp_path / "proj"
|
|
repo.mkdir()
|
|
_git(repo, "init", "-b", "main")
|
|
(repo / "a.txt").write_text("hello\n")
|
|
_git(repo, "add", "-A")
|
|
_git(repo, "commit", "-m", "init")
|
|
monkeypatch.chdir(repo)
|
|
monkeypatch.setattr(cli_mod, "_active_worktree", None)
|
|
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
|
yield repo
|
|
# Leave tmp dir before pytest tears it down (worktree cwd may be inside).
|
|
os.chdir(tmp_path)
|
|
|
|
def test_status_outside_repo(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(cli_mod, "_active_worktree", None)
|
|
out = _run(_Stub(), "/worktree")
|
|
assert t("cli.commands.worktree.not_in_repo_note") in out
|
|
|
|
def test_new_outside_repo(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
out = _run(_Stub(), "/worktree new")
|
|
assert t("cli.commands.worktree.new_requires_repo") in out
|
|
|
|
@requires_git
|
|
def test_list_shows_worktrees(repo):
|
|
out = _run(_Stub(), "/worktree list")
|
|
assert repo.as_posix() in out
|
|
|
|
@requires_git
|
|
def test_new_named_creates_and_retargets(repo):
|
|
out = _run(_Stub(), "/worktree new fix-login")
|
|
wt = repo / ".worktrees" / "fix-login"
|
|
assert t("cli.commands.worktree.ready", path=wt) in out
|
|
assert wt.is_dir()
|
|
assert os.environ["TERMINAL_CWD"] == str(wt)
|
|
assert os.path.realpath(os.getcwd()) == os.path.realpath(str(wt))
|
|
assert cli_mod._active_worktree is not None
|
|
assert cli_mod._active_worktree["branch"] == "hermes/fix-login"
|
|
# Same file contents as the base commit.
|
|
assert (wt / "a.txt").read_text() == "hello\n"
|
|
|
|
@requires_git
|
|
def test_new_named_collision_refused(repo):
|
|
_run(_Stub(), "/worktree new dup")
|
|
first = cli_mod._active_worktree
|
|
os.chdir(repo)
|
|
out = _run(_Stub(), "/worktree new dup")
|
|
assert "already exists" in out
|
|
# Active worktree not clobbered by the failed attempt.
|
|
assert cli_mod._active_worktree == first
|
|
|
|
@requires_git
|
|
def test_new_unnamed_uses_random_hermes_prefix(repo):
|
|
out = _run(_Stub(), "/worktree new")
|
|
assert t("cli.commands.worktree.ready", path=cli_mod._active_worktree["path"]) in out
|
|
name = os.path.basename(cli_mod._active_worktree["path"])
|
|
assert name.startswith("hermes-")
|
|
|
|
@requires_git
|
|
def test_new_sanitizes_name(repo):
|
|
_run(_Stub(), "/worktree new " + "weird name!@#")
|
|
name = os.path.basename(cli_mod._active_worktree["path"])
|
|
assert name == "weird-name"
|
|
|
|
|
|
def _gateway_stub(rpc=None):
|
|
"""A CLI attached to a (scripted) gateway session: the tools run there, not in this process."""
|
|
from hermes_cli.gateway_connection import GatewayConnection
|
|
from hermes_cli.gateway_session_store import GatewaySessionStore
|
|
from tests.hermes_cli.test_gateway_agent import connect_with
|
|
from tests.hermes_cli.test_gateway_session_store import FakeClient
|
|
client = FakeClient(known=("local-s",))
|
|
if rpc is not None:
|
|
client.rpc = rpc.__get__(client)
|
|
conn = GatewayConnection(connect=connect_with(client))
|
|
conn.start(5)
|
|
stub = _Stub()
|
|
stub._session_db, stub._gateway_session_open, stub.session_id = GatewaySessionStore(conn), True, "local-s"
|
|
return stub, client, conn
|
|
|
|
|
|
@requires_git
|
|
def test_new_moves_the_gateway_sessions_workspace_into_the_tree(repo):
|
|
"""CLI-15: the session's tools run in the gateway under its frozen cwd, so /worktree new must move
|
|
that (``session.mutate workspace``, generation-fenced) — a chdir of this process moved nothing."""
|
|
stub, client, conn = _gateway_stub()
|
|
try:
|
|
out = _run(stub, "/worktree new moved")
|
|
finally:
|
|
conn.close()
|
|
wt = repo / ".worktrees" / "moved"
|
|
(params,) = [p for method, p in client.calls if method == "session.mutate"]
|
|
assert (params["operation"], params["payload"], params["session_id"]) == ("workspace", {"cwd": str(wt)}, "local-s")
|
|
assert params["expected_generation"] == 0
|
|
assert t("cli.commands.worktree.tools_in_worktree") in out
|
|
|
|
|
|
@requires_git
|
|
def test_new_refused_by_the_gateway_never_claims_the_tools_moved(repo):
|
|
"""A refused move (e.g. ``session_busy`` mid-turn) leaves the session where it was: say so, and
|
|
keep this process (and a later /new's cwd) in the original checkout too."""
|
|
from hermes_cli.gateway_client import GatewayRPCError
|
|
from tests.hermes_cli.test_gateway_session_store import snapshot
|
|
|
|
async def refuse(self, method, *, _timeout=None, **params):
|
|
self.calls.append((method, params))
|
|
if method == "session.mutate":
|
|
raise GatewayRPCError("session_busy")
|
|
return snapshot("local-s")
|
|
stub, _client, conn = _gateway_stub(refuse)
|
|
try:
|
|
out = _run(stub, "/worktree new busy")
|
|
finally:
|
|
conn.close()
|
|
assert "session_busy" in out and t("cli.commands.worktree.tools_in_worktree") not in out
|
|
assert os.path.realpath(os.getcwd()) == os.path.realpath(str(repo))
|
|
assert "TERMINAL_CWD" not in os.environ and cli_mod._active_worktree is None
|
|
assert not (repo / ".worktrees" / "busy").exists() # not left on disk, unlisted, until quit
|
|
|
|
|
|
@requires_git
|
|
def test_new_whose_shell_cannot_follow_says_the_session_moved(repo, monkeypatch):
|
|
"""The gateway accepted the move, so the session's tools run in the tree; only this process stays
|
|
behind. Say that, instead of "could not enter it" for a session that did move."""
|
|
real_chdir = os.chdir
|
|
|
|
def chdir(path):
|
|
if ".worktrees" in str(path):
|
|
raise PermissionError("denied")
|
|
real_chdir(path)
|
|
stub, _client, conn = _gateway_stub()
|
|
monkeypatch.setattr(os, "chdir", chdir)
|
|
try:
|
|
out = _run(stub, "/worktree new stuck")
|
|
finally:
|
|
conn.close()
|
|
wt = repo / ".worktrees" / "stuck"
|
|
assert t("cli.session.cwd_enter_failed", path=str(wt), error="denied") in out
|
|
assert t("cli.commands.worktree.tools_in_worktree") in out and cli_mod._active_worktree["path"] == str(wt)
|
|
assert "TERMINAL_CWD" not in os.environ
|
|
|
|
|
|
@requires_git
|
|
def test_a_moved_session_gets_its_clean_tree_back_on_resume(repo):
|
|
"""The move freezes the tree as the session's cwd and quit removes a clean tree, so a resume had
|
|
every turn refused ``workspace_unavailable``. The tree is recorded as a ``--tui -w`` one is, so the
|
|
resume recreates it at the same path (N8)."""
|
|
from hermes_cli.tui_worktree_resume import recreate_tui_worktree
|
|
stub, _client, conn = _gateway_stub()
|
|
try:
|
|
_run(stub, "/worktree new kept")
|
|
finally:
|
|
conn.close()
|
|
wt = repo / ".worktrees" / "kept"
|
|
os.chdir(repo)
|
|
cli_mod._cleanup_worktree(cli_mod._active_worktree) # quit: the tree is clean, so it goes
|
|
assert not wt.exists()
|
|
assert recreate_tui_worktree(str(wt), "local-s") == str(wt) and wt.is_dir()
|