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:
@@ -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 |
|
||||||
|
|||||||
@@ -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
106
cli.py
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user