diff --git a/AGENTS.md b/AGENTS.md index cdd26723a..5001e1d7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,6 +226,7 @@ The unified `hermes` command provides all functionality: |---------|-------------| | `hermes` | Interactive chat (default) | | `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 config` | View current configuration | | `hermes config edit` | Open config in editor | diff --git a/cli-config.yaml.example b/cli-config.yaml.example index f0d5a95bd..dfbaeee6b 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -50,6 +50,16 @@ model: # # Data policy: "allow" (default) or "deny" to exclude providers that may store data # # 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 # ============================================================================= diff --git a/cli.py b/cli.py index ccef54ab9..6c44ef61b 100755 --- a/cli.py +++ b/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) # Create the worktree - result = subprocess.run( - ["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") + try: + result = subprocess.run( + ["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") + return None + except Exception as e: + print(f"\033[31m✗ Failed to create worktree: {e}\033[0m") return None # 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 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 # ============================================================================ @@ -3456,17 +3520,25 @@ def main( asyncio.run(start_gateway()) return - # ── Git worktree isolation (#652) ── - # Create an isolated worktree so this agent instance doesn't collide - # with other agents working on the same repo. - use_worktree = worktree or w or CLI_CONFIG.get("worktree", False) - wt_info = None - if use_worktree: - wt_info = _setup_worktree() - if wt_info: - _active_worktree = wt_info - os.environ["TERMINAL_CWD"] = wt_info["path"] - atexit.register(_cleanup_worktree, wt_info) + # Skip worktree for list commands (they exit immediately) + if not list_tools and not list_toolsets: + # ── Git worktree isolation (#652) ── + # Create an isolated worktree so this agent instance doesn't collide + # with other agents working on the same repo. + use_worktree = worktree or w or CLI_CONFIG.get("worktree", False) + wt_info = None + if use_worktree: + # Prune stale worktrees from crashed/killed sessions + _repo = _git_repo_root() + 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 query = query or q diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 55c41e37b..20f33998a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -167,6 +167,7 @@ def cmd_chat(args): "verbose": args.verbose, "query": args.query, "resume": getattr(args, "resume", None), + "worktree": getattr(args, "worktree", False), } # Filter out None values 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 set model gpt-4 Set a config value hermes gateway Run messaging gateway + hermes -w Start in isolated git worktree hermes gateway install Install as system service hermes sessions list List past sessions hermes update Update to latest version @@ -1244,6 +1246,12 @@ For more help on a command: default=False, 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") @@ -1290,6 +1298,12 @@ For more help on a command: default=False, 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) # ========================================================================= @@ -1850,6 +1864,8 @@ For more help on a command: args.provider = None args.toolsets = None args.verbose = False + if not hasattr(args, "worktree"): + args.worktree = False cmd_chat(args) return @@ -1862,6 +1878,8 @@ For more help on a command: args.verbose = False args.resume = None args.continue_last = False + if not hasattr(args, "worktree"): + args.worktree = False cmd_chat(args) return diff --git a/tests/test_worktree.py b/tests/test_worktree.py index ab943b41e..f545baa39 100644 --- a/tests/test_worktree.py +++ b/tests/test_worktree.py @@ -1,20 +1,15 @@ """Tests for git worktree isolation (CLI --worktree / -w flag). Verifies worktree creation, cleanup, .worktreeinclude handling, -and .gitignore management. (#652) +.gitignore management, and integration with the CLI. (#652) """ import os +import shutil import subprocess import pytest from pathlib import Path -from unittest.mock import patch - -# 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 +from unittest.mock import patch, MagicMock @pytest.fixture @@ -41,19 +36,6 @@ def git_repo(tmp_path): 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) # --------------------------------------------------------------------------- @@ -397,3 +379,257 @@ class TestMultipleWorktrees: # All should be removed for info in worktrees: 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 diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index d142bb4bf..bb40bbdeb 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -22,6 +22,7 @@ These are commands you run from your shell. | `hermes chat --provider ` | Force a provider (`nous`, `openrouter`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`) | | `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets | | `hermes chat --verbose` | Enable verbose/debug output | +| `hermes --worktree` / `-w` | Start in an isolated git worktree (for parallel agents) | ### Provider & Model Management diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index df07739c2..ec89c7b58 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -33,6 +33,10 @@ hermes --resume # Resume a specific session by ID (-r) # Verbose mode (debug output) 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 diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 33193619c..07096a189 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -407,6 +407,26 @@ memory: 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 ```yaml