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:
parent
db407c8c93
commit
3ed1d2ed61
25
cli.py
25
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 <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":
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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). |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue