fix: wire worktree flag into hermes CLI entry point + docs + tests

Critical fixes:
- Add --worktree/-w to hermes_cli/main.py argparse (both chat
  subcommand and top-level parser) so 'hermes -w' works via the
  actual CLI entry point, not just 'python cli.py -w'
- Pass worktree flag through cmd_chat() kwargs to cli_main()
- Handle worktree attr in bare 'hermes' and --resume/--continue paths

Bug fixes in cli.py:
- Skip worktree creation for --list-tools/--list-toolsets (wasteful)
- Wrap git worktree subprocess.run in try/except (crash on timeout)
- Add stale worktree pruning on startup (_prune_stale_worktrees):
  removes clean worktrees older than 24h left by crashed/killed sessions

Documentation updates:
- AGENTS.md: add --worktree to CLI commands table
- cli-config.yaml.example: add worktree config section
- website/docs/reference/cli-commands.md: add to core commands
- website/docs/user-guide/cli.md: add usage examples
- website/docs/user-guide/configuration.md: add config docs

Test improvements (17 → 31 tests):
- Stale worktree pruning (prune old clean, keep recent, keep dirty)
- Directory symlink via .worktreeinclude
- Edge cases (no commits, not a repo, pre-existing .worktrees/)
- CLI flag/config OR logic
- TERMINAL_CWD integration
- System prompt injection format
This commit is contained in:
teknium1
2026-03-07 21:05:40 -08:00
parent 8d719b180a
commit 4be783446a
8 changed files with 400 additions and 38 deletions

View File

@@ -226,6 +226,7 @@ The unified `hermes` command provides all functionality:
|---------|-------------| |---------|-------------|
| `hermes` | Interactive chat (default) | | `hermes` | Interactive chat (default) |
| `hermes chat -q "..."` | Single query mode | | `hermes chat -q "..."` | Single query mode |
| `hermes -w` / `hermes --worktree` | Start in isolated git worktree (for parallel agents) |
| `hermes setup` | Configure API keys and settings | | `hermes setup` | Configure API keys and settings |
| `hermes config` | View current configuration | | `hermes config` | View current configuration |
| `hermes config edit` | Open config in editor | | `hermes config edit` | Open config in editor |

View File

@@ -50,6 +50,16 @@ model:
# # Data policy: "allow" (default) or "deny" to exclude providers that may store data # # Data policy: "allow" (default) or "deny" to exclude providers that may store data
# # data_collection: "deny" # # data_collection: "deny"
# =============================================================================
# Git Worktree Isolation
# =============================================================================
# When enabled, each CLI session creates an isolated git worktree so multiple
# agents can work on the same repo concurrently without file collisions.
# Equivalent to always passing --worktree / -w on the command line.
#
# worktree: true # Always create a worktree when in a git repo
# worktree: false # Default — only create when -w flag is passed
# ============================================================================= # =============================================================================
# Terminal Tool Configuration # Terminal Tool Configuration
# ============================================================================= # =============================================================================

106
cli.py
View File

@@ -455,12 +455,16 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
logger.debug("Could not update .gitignore: %s", e) logger.debug("Could not update .gitignore: %s", e)
# Create the worktree # Create the worktree
result = subprocess.run( try:
["git", "worktree", "add", str(wt_path), "-b", branch_name, "HEAD"], result = subprocess.run(
capture_output=True, text=True, timeout=30, cwd=repo_root, ["git", "worktree", "add", str(wt_path), "-b", branch_name, "HEAD"],
) capture_output=True, text=True, timeout=30, cwd=repo_root,
if result.returncode != 0: )
print(f"\033[31m✗ Failed to create worktree: {result.stderr.strip()}\033[0m") if result.returncode != 0:
print(f"\033[31m✗ Failed to create worktree: {result.stderr.strip()}\033[0m")
return None
except Exception as e:
print(f"\033[31m✗ Failed to create worktree: {e}\033[0m")
return None return None
# Copy files listed in .worktreeinclude (gitignored files the agent needs) # Copy files listed in .worktreeinclude (gitignored files the agent needs)
@@ -552,6 +556,66 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
_active_worktree = None _active_worktree = None
print(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m") print(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m")
def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
"""Remove worktrees older than max_age_hours that have no uncommitted changes.
Runs silently on startup to clean up after crashed/killed sessions.
"""
import subprocess
import time
worktrees_dir = Path(repo_root) / ".worktrees"
if not worktrees_dir.exists():
return
now = time.time()
cutoff = now - (max_age_hours * 3600)
for entry in worktrees_dir.iterdir():
if not entry.is_dir() or not entry.name.startswith("hermes-"):
continue
# Check age
try:
mtime = entry.stat().st_mtime
if mtime > cutoff:
continue # Too recent — skip
except Exception:
continue
# Check for uncommitted changes
try:
status = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, timeout=5, cwd=str(entry),
)
if status.stdout.strip():
continue # Has changes — skip
except Exception:
continue # Can't check — skip
# Safe to remove
try:
branch_result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, timeout=5, cwd=str(entry),
)
branch = branch_result.stdout.strip()
subprocess.run(
["git", "worktree", "remove", str(entry), "--force"],
capture_output=True, text=True, timeout=15, cwd=repo_root,
)
if branch:
subprocess.run(
["git", "branch", "-D", branch],
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
logger.debug("Pruned stale worktree: %s", entry.name)
except Exception as e:
logger.debug("Failed to prune worktree %s: %s", entry.name, e)
# ============================================================================ # ============================================================================
# ASCII Art & Branding # ASCII Art & Branding
# ============================================================================ # ============================================================================
@@ -3456,17 +3520,25 @@ def main(
asyncio.run(start_gateway()) asyncio.run(start_gateway())
return return
# ── Git worktree isolation (#652) ── # Skip worktree for list commands (they exit immediately)
# Create an isolated worktree so this agent instance doesn't collide if not list_tools and not list_toolsets:
# with other agents working on the same repo. # ── Git worktree isolation (#652) ──
use_worktree = worktree or w or CLI_CONFIG.get("worktree", False) # Create an isolated worktree so this agent instance doesn't collide
wt_info = None # with other agents working on the same repo.
if use_worktree: use_worktree = worktree or w or CLI_CONFIG.get("worktree", False)
wt_info = _setup_worktree() wt_info = None
if wt_info: if use_worktree:
_active_worktree = wt_info # Prune stale worktrees from crashed/killed sessions
os.environ["TERMINAL_CWD"] = wt_info["path"] _repo = _git_repo_root()
atexit.register(_cleanup_worktree, wt_info) if _repo:
_prune_stale_worktrees(_repo)
wt_info = _setup_worktree()
if wt_info:
_active_worktree = wt_info
os.environ["TERMINAL_CWD"] = wt_info["path"]
atexit.register(_cleanup_worktree, wt_info)
else:
wt_info = None
# Handle query shorthand # Handle query shorthand
query = query or q query = query or q

View File

@@ -167,6 +167,7 @@ def cmd_chat(args):
"verbose": args.verbose, "verbose": args.verbose,
"query": args.query, "query": args.query,
"resume": getattr(args, "resume", None), "resume": getattr(args, "resume", None),
"worktree": getattr(args, "worktree", False),
} }
# Filter out None values # Filter out None values
kwargs = {k: v for k, v in kwargs.items() if v is not None} kwargs = {k: v for k, v in kwargs.items() if v is not None}
@@ -1217,6 +1218,7 @@ Examples:
hermes config edit Edit config in $EDITOR hermes config edit Edit config in $EDITOR
hermes config set model gpt-4 Set a config value hermes config set model gpt-4 Set a config value
hermes gateway Run messaging gateway hermes gateway Run messaging gateway
hermes -w Start in isolated git worktree
hermes gateway install Install as system service hermes gateway install Install as system service
hermes sessions list List past sessions hermes sessions list List past sessions
hermes update Update to latest version hermes update Update to latest version
@@ -1244,6 +1246,12 @@ For more help on a command:
default=False, default=False,
help="Resume the most recent CLI session" help="Resume the most recent CLI session"
) )
parser.add_argument(
"--worktree", "-w",
action="store_true",
default=False,
help="Run in an isolated git worktree (for parallel agents)"
)
subparsers = parser.add_subparsers(dest="command", help="Command to run") subparsers = parser.add_subparsers(dest="command", help="Command to run")
@@ -1290,6 +1298,12 @@ For more help on a command:
default=False, default=False,
help="Resume the most recent CLI session" help="Resume the most recent CLI session"
) )
chat_parser.add_argument(
"--worktree", "-w",
action="store_true",
default=False,
help="Run in an isolated git worktree (for parallel agents on the same repo)"
)
chat_parser.set_defaults(func=cmd_chat) chat_parser.set_defaults(func=cmd_chat)
# ========================================================================= # =========================================================================
@@ -1850,6 +1864,8 @@ For more help on a command:
args.provider = None args.provider = None
args.toolsets = None args.toolsets = None
args.verbose = False args.verbose = False
if not hasattr(args, "worktree"):
args.worktree = False
cmd_chat(args) cmd_chat(args)
return return
@@ -1862,6 +1878,8 @@ For more help on a command:
args.verbose = False args.verbose = False
args.resume = None args.resume = None
args.continue_last = False args.continue_last = False
if not hasattr(args, "worktree"):
args.worktree = False
cmd_chat(args) cmd_chat(args)
return return

View File

@@ -1,20 +1,15 @@
"""Tests for git worktree isolation (CLI --worktree / -w flag). """Tests for git worktree isolation (CLI --worktree / -w flag).
Verifies worktree creation, cleanup, .worktreeinclude handling, Verifies worktree creation, cleanup, .worktreeinclude handling,
and .gitignore management. (#652) .gitignore management, and integration with the CLI. (#652)
""" """
import os import os
import shutil
import subprocess import subprocess
import pytest import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch, MagicMock
# Import worktree functions from cli.py
# We need to be careful — cli.py has heavy imports at module level.
# Import the functions directly.
import importlib
import sys
@pytest.fixture @pytest.fixture
@@ -41,19 +36,6 @@ def git_repo(tmp_path):
return repo return repo
@pytest.fixture
def worktree_funcs():
"""Import worktree functions without triggering heavy cli.py imports."""
# We test the functions in isolation using subprocess calls
# that mirror what the functions do, since importing cli.py
# pulls in prompt_toolkit, rich, fire, etc.
return {
"git_repo_root": _git_repo_root,
"setup_worktree": _setup_worktree,
"cleanup_worktree": _cleanup_worktree,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lightweight reimplementations for testing (avoid importing cli.py) # Lightweight reimplementations for testing (avoid importing cli.py)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -397,3 +379,257 @@ class TestMultipleWorktrees:
# All should be removed # All should be removed
for info in worktrees: for info in worktrees:
assert not Path(info["path"]).exists() assert not Path(info["path"]).exists()
class TestWorktreeDirectorySymlink:
"""Test .worktreeinclude with directories (symlinked)."""
def test_symlinks_directory(self, git_repo):
"""Directories in .worktreeinclude should be symlinked."""
# Create a .venv directory
venv_dir = git_repo / ".venv" / "lib"
venv_dir.mkdir(parents=True)
(venv_dir / "marker.txt").write_text("venv marker")
(git_repo / ".gitignore").write_text(".venv/\n.worktrees/\n")
subprocess.run(
["git", "add", ".gitignore"], cwd=str(git_repo), capture_output=True
)
subprocess.run(
["git", "commit", "-m", "gitignore"], cwd=str(git_repo), capture_output=True
)
(git_repo / ".worktreeinclude").write_text(".venv/\n")
info = _setup_worktree(str(git_repo))
assert info is not None
wt_path = Path(info["path"])
src = git_repo / ".venv"
dst = wt_path / ".venv"
# Manually symlink (mirrors cli.py logic)
if not dst.exists():
dst.parent.mkdir(parents=True, exist_ok=True)
os.symlink(str(src.resolve()), str(dst))
assert dst.is_symlink()
assert (dst / "lib" / "marker.txt").read_text() == "venv marker"
class TestStaleWorktreePruning:
"""Test _prune_stale_worktrees garbage collection."""
def test_prunes_old_clean_worktree(self, git_repo):
"""Old clean worktrees should be removed on prune."""
import time
info = _setup_worktree(str(git_repo))
assert info is not None
assert Path(info["path"]).exists()
# Make the worktree look old (set mtime to 25h ago)
old_time = time.time() - (25 * 3600)
os.utime(info["path"], (old_time, old_time))
# Reimplementation of prune logic (matches cli.py)
worktrees_dir = git_repo / ".worktrees"
cutoff = time.time() - (24 * 3600)
for entry in worktrees_dir.iterdir():
if not entry.is_dir() or not entry.name.startswith("hermes-"):
continue
try:
mtime = entry.stat().st_mtime
if mtime > cutoff:
continue
except Exception:
continue
status = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, timeout=5, cwd=str(entry),
)
if status.stdout.strip():
continue
branch_result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, timeout=5, cwd=str(entry),
)
branch = branch_result.stdout.strip()
subprocess.run(
["git", "worktree", "remove", str(entry), "--force"],
capture_output=True, text=True, timeout=15, cwd=str(git_repo),
)
if branch:
subprocess.run(
["git", "branch", "-D", branch],
capture_output=True, text=True, timeout=10, cwd=str(git_repo),
)
assert not Path(info["path"]).exists()
def test_keeps_recent_worktree(self, git_repo):
"""Recent worktrees should NOT be pruned."""
import time
info = _setup_worktree(str(git_repo))
assert info is not None
# Don't modify mtime — it's recent
worktrees_dir = git_repo / ".worktrees"
cutoff = time.time() - (24 * 3600)
pruned = False
for entry in worktrees_dir.iterdir():
if not entry.is_dir() or not entry.name.startswith("hermes-"):
continue
mtime = entry.stat().st_mtime
if mtime > cutoff:
continue # Too recent
pruned = True
assert not pruned
assert Path(info["path"]).exists()
def test_keeps_dirty_old_worktree(self, git_repo):
"""Old worktrees with uncommitted changes should NOT be pruned."""
import time
info = _setup_worktree(str(git_repo))
assert info is not None
# Make it dirty
(Path(info["path"]) / "dirty.txt").write_text("uncommitted")
subprocess.run(
["git", "add", "dirty.txt"],
cwd=info["path"], capture_output=True,
)
# Make it old
old_time = time.time() - (25 * 3600)
os.utime(info["path"], (old_time, old_time))
# Check if it would be pruned
status = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, cwd=info["path"],
)
has_changes = bool(status.stdout.strip())
assert has_changes # Should be dirty → not pruned
assert Path(info["path"]).exists()
class TestEdgeCases:
"""Test edge cases for robustness."""
def test_no_commits_repo(self, tmp_path):
"""Worktree creation should fail gracefully on a repo with no commits."""
repo = tmp_path / "empty-repo"
repo.mkdir()
subprocess.run(["git", "init"], cwd=str(repo), capture_output=True)
info = _setup_worktree(str(repo))
assert info is None # Should fail gracefully
def test_not_a_git_repo(self, tmp_path):
"""Repo detection should return None for non-git directories."""
bare = tmp_path / "not-git"
bare.mkdir()
root = _git_repo_root(cwd=str(bare))
assert root is None
def test_worktrees_dir_already_exists(self, git_repo):
"""Should work fine if .worktrees/ already exists."""
(git_repo / ".worktrees").mkdir(exist_ok=True)
info = _setup_worktree(str(git_repo))
assert info is not None
assert Path(info["path"]).exists()
class TestCLIFlagLogic:
"""Test the flag/config OR logic from main()."""
def test_worktree_flag_triggers(self):
"""--worktree flag should trigger worktree creation."""
worktree = True
w = False
config_worktree = False
use_worktree = worktree or w or config_worktree
assert use_worktree
def test_w_flag_triggers(self):
"""-w flag should trigger worktree creation."""
worktree = False
w = True
config_worktree = False
use_worktree = worktree or w or config_worktree
assert use_worktree
def test_config_triggers(self):
"""worktree: true in config should trigger worktree creation."""
worktree = False
w = False
config_worktree = True
use_worktree = worktree or w or config_worktree
assert use_worktree
def test_none_set_no_trigger(self):
"""No flags and no config should not trigger."""
worktree = False
w = False
config_worktree = False
use_worktree = worktree or w or config_worktree
assert not use_worktree
class TestTerminalCWDIntegration:
"""Test that TERMINAL_CWD is correctly set to the worktree path."""
def test_terminal_cwd_set(self, git_repo):
"""After worktree setup, TERMINAL_CWD should point to the worktree."""
info = _setup_worktree(str(git_repo))
assert info is not None
# This is what main() does:
os.environ["TERMINAL_CWD"] = info["path"]
assert os.environ["TERMINAL_CWD"] == info["path"]
assert Path(os.environ["TERMINAL_CWD"]).exists()
# Clean up env
del os.environ["TERMINAL_CWD"]
def test_terminal_cwd_is_valid_git_repo(self, git_repo):
"""The TERMINAL_CWD worktree should be a valid git working tree."""
info = _setup_worktree(str(git_repo))
assert info is not None
result = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True, cwd=info["path"],
)
assert result.stdout.strip() == "true"
class TestSystemPromptInjection:
"""Test that the agent gets worktree context in its system prompt."""
def test_prompt_note_format(self, git_repo):
"""Verify the system prompt note contains all required info."""
info = _setup_worktree(str(git_repo))
assert info is not None
# This is what main() does:
wt_note = (
f"\n\n[System note: You are working in an isolated git worktree at "
f"{info['path']}. Your branch is `{info['branch']}`. "
f"Changes here do not affect the main working tree or other agents. "
f"Remember to commit and push your changes, and create a PR if appropriate. "
f"The original repo is at {info['repo_root']}.]"
)
assert info["path"] in wt_note
assert info["branch"] in wt_note
assert info["repo_root"] in wt_note
assert "isolated git worktree" in wt_note
assert "commit and push" in wt_note

View File

@@ -22,6 +22,7 @@ These are commands you run from your shell.
| `hermes chat --provider <name>` | Force a provider (`nous`, `openrouter`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`) | | `hermes chat --provider <name>` | Force a provider (`nous`, `openrouter`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`) |
| `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets | | `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets |
| `hermes chat --verbose` | Enable verbose/debug output | | `hermes chat --verbose` | Enable verbose/debug output |
| `hermes --worktree` / `-w` | Start in an isolated git worktree (for parallel agents) |
### Provider & Model Management ### Provider & Model Management

View File

@@ -33,6 +33,10 @@ hermes --resume <session_id> # Resume a specific session by ID (-r)
# Verbose mode (debug output) # Verbose mode (debug output)
hermes chat --verbose hermes chat --verbose
# Isolated git worktree (for running multiple agents in parallel)
hermes -w # Interactive mode in worktree
hermes -w -q "Fix issue #123" # Single query in worktree
``` ```
## Interface Layout ## Interface Layout

View File

@@ -407,6 +407,26 @@ memory:
user_char_limit: 1375 # ~500 tokens user_char_limit: 1375 # ~500 tokens
``` ```
## Git Worktree Isolation
Enable isolated git worktrees for running multiple agents in parallel on the same repo:
```yaml
worktree: true # Always create a worktree (same as hermes -w)
# worktree: false # Default — only when -w flag is passed
```
When enabled, each CLI session creates a fresh worktree under `.worktrees/` with its own branch. Agents can edit files, commit, push, and create PRs without interfering with each other. Clean worktrees are removed on exit; dirty ones are kept for manual recovery.
You can also list gitignored files to copy into worktrees via `.worktreeinclude` in your repo root:
```
# .worktreeinclude
.env
.venv/
node_modules/
```
## Context Compression ## Context Compression
```yaml ```yaml