Inspired by Copilot CLI: /worktree — create isolated git worktrees mid-session

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.
This commit is contained in:
Teknium 2026-08-06 22:14:01 -07:00
parent db407c8c93
commit 3ed1d2ed61
No known key found for this signature in database
6 changed files with 268 additions and 4 deletions

25
cli.py
View File

@ -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 <name>``), the worktree directory
and branch use the sanitized name instead of a random ``hermes-<id>``.
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":

View File

@ -1168,6 +1168,98 @@ class CLICommandsMixin:
# /sessions <id_or_title> behaves the same as /resume <id_or_title>.
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.

View File

@ -127,6 +127,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="<platform>", 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",

View File

@ -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

View File

@ -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 <prompt>` (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 <id>\|edit <id>]` (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/<name>` branch); unnamed ones get a random `hermes-<id>`. 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 <platform>` | **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 <title>`. 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). |

View File

@ -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