From 3ed1d2ed61dd7ecebd756016fc5e3f44a7670de1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:14:01 -0700 Subject: [PATCH] =?UTF-8?q?Inspired=20by=20Copilot=20CLI:=20/worktree=20?= =?UTF-8?q?=E2=80=94=20create=20isolated=20git=20worktrees=20mid-session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot CLI 1.0.79-3 added /worktree new (start a session in a new worktree). Hermes already has hermes -w launch-time isolation; this adds the mid-session counterpart: /worktree new [name] creates a tree under .worktrees/ (remote-tip base, worktree_sync honored), retargets TERMINAL_CWD + process cwd, and registers the same keep-if-unpushed exit cleanup. /worktree shows the active tree; /worktree list lists them. Named trees skip the hermes- prefix so the startup pruner ages them on the slower named-tree schedule. --- cli.py | 25 ++++- hermes_cli/cli_commands_mixin.py | 92 +++++++++++++++ hermes_cli/commands.py | 3 + tests/hermes_cli/test_worktree_command.py | 131 ++++++++++++++++++++++ website/docs/reference/slash-commands.md | 2 +- website/docs/user-guide/git-worktrees.md | 19 ++++ 6 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 tests/hermes_cli/test_worktree_command.py diff --git a/cli.py b/cli.py index 26e202711d477..84771f6ed916f 100644 --- a/cli.py +++ b/cli.py @@ -1605,7 +1605,8 @@ def _resolve_worktree_base( return "HEAD", "HEAD (local — could not reach remote)" -def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[Dict[str, str]]: +def _setup_worktree(repo_root: str = None, sync_base: bool = True, + name: Optional[str] = None) -> Optional[Dict[str, str]]: """Create an isolated git worktree for this CLI session. Returns a dict with worktree metadata on success, None on failure. @@ -1615,6 +1616,11 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D freshly-fetched remote tip rather than the (possibly stale) local ``HEAD`` — see ``_resolve_worktree_base``. Set ``worktree_sync: false`` in config to branch from local ``HEAD`` (the pre-#10760-followup behavior). + + When *name* is given (``/worktree new ``), the worktree directory + and branch use the sanitized name instead of a random ``hermes-``. + Named trees intentionally skip the ``hermes-`` prefix so the startup + pruner ages them on its slower named-tree schedule. """ import subprocess @@ -1624,14 +1630,25 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D print(" cd into your project repo first, then run hermes -w") return None - short_id = uuid.uuid4().hex[:8] - wt_name = f"hermes-{short_id}" + if name: + safe = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-._")[:40] + if safe: + wt_name = safe + else: + wt_name = f"hermes-{uuid.uuid4().hex[:8]}" + else: + wt_name = f"hermes-{uuid.uuid4().hex[:8]}" branch_name = f"hermes/{wt_name}" worktrees_dir = Path(repo_root) / ".worktrees" worktrees_dir.mkdir(parents=True, exist_ok=True) wt_path = worktrees_dir / wt_name + if name and wt_path.exists(): + print(f"\033[31m✗ Worktree already exists: {wt_path}\033[0m") + print(" Pick a different name, or remove it with: " + f"git worktree remove {wt_path}") + return None # Ensure .worktrees/ is in .gitignore gitignore = Path(repo_root) / ".gitignore" @@ -10082,6 +10099,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self.undo_last(_undo_n) elif canonical == "branch": self._handle_branch_command(cmd_original) + elif canonical == "worktree": + self._handle_worktree_command(cmd_original) elif canonical == "save": self.save_conversation() elif canonical == "cron": diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 50bb4f7345b26..0ae698abdc9a3 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1168,6 +1168,98 @@ class CLICommandsMixin: # /sessions behaves the same as /resume . self._handle_resume_command(f"/resume {arg}") + def _handle_worktree_command(self, cmd_original: str) -> None: + """Handle /worktree — inspect or create isolated git worktrees. + + Syntax: + /worktree — show the active worktree (if any) + /worktree new [name] — create a worktree and move this session into it + /worktree list — list worktrees under the repo's .worktrees/ + + Inspired by Copilot CLI's ``/worktree new``: start isolated work in a + fresh worktree without leaving the session. Creating one retargets the + terminal/file tools (``TERMINAL_CWD`` + process cwd) at the new tree; + the launcher's exit cleanup applies (kept only when it has unpushed + commits, same as ``hermes -w``). + """ + import subprocess + + import cli as _cli + + parts = cmd_original.split(None, 2) + sub = parts[1].lower() if len(parts) > 1 else "" + + repo_root = _cli._git_repo_root() + + if not sub or sub in {"status", "show"}: + active = _cli._active_worktree + if active: + print(f" Active worktree: {active['path']}") + print(f" Branch: {active['branch']}") + else: + print(" No active worktree for this session.") + if repo_root: + print(" /worktree new [name] — create one and move this session into it") + else: + print(" (not inside a git repository)") + return + + if sub in {"list", "ls"}: + if not repo_root: + print(" Not inside a git repository.") + return + try: + result = subprocess.run( + ["git", "worktree", "list"], + capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=10, cwd=repo_root, + ) + out = result.stdout.strip() if result.returncode == 0 else "" + except Exception: + out = "" + if out: + for line in out.splitlines(): + print(f" {line}") + else: + print(" Could not list worktrees.") + return + + if sub in {"new", "add", "create"}: + if not repo_root: + print(" ❌ /worktree new requires being inside a git repository.") + return + name = parts[2].strip() if len(parts) > 2 else None + from hermes_cli.config import load_config + try: + sync_base = bool(load_config().get("worktree_sync", True)) + except Exception: + sync_base = True + wt_info = _cli._setup_worktree( + repo_root=repo_root, sync_base=sync_base, name=name, + ) + if not wt_info: + return # _setup_worktree already printed the failure + # Retarget the session's terminal/file tools at the new tree, the + # same way `hermes -w` and session-resume cwd restore do. + try: + os.chdir(wt_info["path"]) + except OSError as e: + print(f" ⚠ Created worktree but could not enter it: {e}") + os.environ["TERMINAL_CWD"] = wt_info["path"] + # Register for the same keep-if-unpushed cleanup as `hermes -w`. + # Only one worktree is tracked as "active" per process; an earlier + # one keeps its own atexit registration (explicit info arg). + import atexit + _cli._active_worktree = wt_info + atexit.register(_cli._cleanup_worktree, wt_info) + print(f" ✅ Worktree ready: {wt_info['path']}") + print(f" Branch: {wt_info['branch']}") + print(" Terminal and file tools now operate in the worktree.") + return + + print(f" Unknown /worktree subcommand: {sub}") + print(" Usage: /worktree [new [name] | list]") + def _handle_branch_command(self, cmd_original: str) -> None: """Handle /branch [name] — fork the current session into a new independent copy. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f3323f020ae1d..68d76150070f7 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -127,6 +127,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="", cli_only=True), CommandDef("branch", "Branch the current session (explore a different path)", "Session", aliases=("fork",), args_hint="[name]"), + CommandDef("worktree", "Show, list, or create isolated git worktrees for this session", "Session", + cli_only=True, args_hint="[new [name]|list]", + subcommands=("new", "list")), CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns; --preview shows what would happen)", "Session", aliases=("compact",), args_hint="[here [N] | focus topic | --preview|--dry-run]"), CommandDef("rollback", "List or restore filesystem checkpoints", "Session", diff --git a/tests/hermes_cli/test_worktree_command.py b/tests/hermes_cli/test_worktree_command.py new file mode 100644 index 0000000000000..af6563794fb31 --- /dev/null +++ b/tests/hermes_cli/test_worktree_command.py @@ -0,0 +1,131 @@ +"""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 + +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): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + 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) + + +@requires_git +def test_status_no_active_worktree(repo): + out = _run(_Stub(), "/worktree") + assert "No active worktree" in out + assert "/worktree new" in out + + +def test_status_outside_repo(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cli_mod, "_active_worktree", None) + out = _run(_Stub(), "/worktree") + assert "not inside a git repository" in out + + +def test_new_outside_repo(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + out = _run(_Stub(), "/worktree new") + assert "requires being inside a git repository" in out + + +@requires_git +def test_list_shows_worktrees(repo): + out = _run(_Stub(), "/worktree list") + assert str(repo) in out + + +@requires_git +def test_new_named_creates_and_retargets(repo): + out = _run(_Stub(), "/worktree new fix-login") + assert "Worktree ready" in out + wt = repo / ".worktrees" / "fix-login" + 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 "Worktree ready" 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" + + +@requires_git +def test_unknown_subcommand(repo): + out = _run(_Stub(), "/worktree frobnicate") + assert "Unknown /worktree subcommand" in out diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index c5f646fa6acb4..89f0a18091330 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -65,7 +65,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | | `/background ` (alias: `/bg`, `/btw`) | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/user-guide/cli#background-sessions). | | `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path) | -| `/journey [list\|delete \|edit ]` (aliases: `/learning`, `/memory-graph`) | **CLI only.** Open the learning journey timeline. | +| `/worktree [new [name]\|list]` | **CLI only.** Inspect or create isolated git worktrees mid-session (inspired by Copilot CLI's `/worktree new`). Bare `/worktree` shows the active worktree; `/worktree list` lists the repo's worktrees; `/worktree new [name]` creates a worktree under `.worktrees/` (branched from the freshly-fetched remote tip, honoring `worktree_sync`) and retargets the session's terminal and file tools into it. Named trees use your name (`hermes/` branch); unnamed ones get a random `hermes-`. On exit the tree is kept only if it has unpushed commits — same lifecycle as `hermes -w`. See [Git Worktrees](/user-guide/git-worktrees). | | `/handoff ` | **CLI only.** Hand the current session off to a messaging platform (Telegram, Discord, Slack, WhatsApp, Signal, Matrix). The gateway picks it up immediately, creates a fresh thread on platforms that support threads (Telegram topics, Discord text-channel threads, Slack message-anchored threads), re-binds the destination to your CLI session_id so the full role-aware transcript replays, and forges a synthetic user turn so the agent confirms it's working in the new place. Your CLI exits cleanly on success with a `/resume` hint; resume locally any time with `/resume `. Refused mid-turn. Requires the gateway to be running and a home channel configured for the target platform (`/sethome` from the destination chat). See [Cross-Platform Handoff](/user-guide/sessions#cross-platform-handoff). | | `/journey [list\|delete <id>\|edit <id>]` (aliases: `/learning`, `/memory-graph`) | Open the learning journey timeline of learned skills + memories. Works in the classic CLI, as a TUI overlay, and in the desktop app (Star Map panel). Not available on messaging platforms. See [Learning Journey](/user-guide/features/memory#learning-journey-journey). | diff --git a/website/docs/user-guide/git-worktrees.md b/website/docs/user-guide/git-worktrees.md index 2d665135a2999..98b65f3092ae8 100644 --- a/website/docs/user-guide/git-worktrees.md +++ b/website/docs/user-guide/git-worktrees.md @@ -37,6 +37,25 @@ See also: [Checkpoints and /rollback](./checkpoints-and-rollback.md). ## Quick Start: Creating a Worktree +### From inside a session: `/worktree new` + +The fastest path (inspired by Copilot CLI's `/worktree new`): from an +interactive CLI session, run + +``` +/worktree new my-experiment +``` + +Hermes creates `.worktrees/my-experiment/` inside the repo (branch +`hermes/my-experiment`, based on the freshly-fetched remote tip unless +`worktree_sync: false`), and retargets the session's terminal and file tools +into it — no restart needed. Omit the name to get a random `hermes-<id>` +tree. `/worktree` alone shows the active tree; `/worktree list` lists all of +them. On exit the tree is kept only if it has unpushed commits, exactly like +`hermes -w`. + +### Manually with git + From your main repository (containing `.git/`), create a new worktree for a feature branch: ```bash