orchestrata_ultimate.tools.worktree_tools
Reference › orchestrata_ultimate › tools › worktree_tools
This module provides the git worktree lifecycle tools of SPEC-064: EnterWorktreeTool (enter_worktree) and ExitWorktreeTool (exit_worktree). They run real git worktree commands so a caller can work in an isolated worktree and return safely to the main repository. agents.tool_registry registers them and the package __init__ exports them. tests/tools/test_worktree_tools.py covers them.
The module records a fork from the spec. There is no ambient session directory in this codebase, so state is self-contained: a per-process dictionary from a caller-supplied session_id to a _SessionWorktreeState, guarded by a lock for free-threaded safety. Each call also accepts a cwd override.
EnterWorktreeTool.execute can list worktrees (list_only), enter an existing one, or create and enter a new one. It requires a session_id, resolves the worktree path, checks it stays within bounds of the main repository, and validates branch names. Creation enforces a protected-branch list, taken from an argument or from DEFAULT_PROTECTED_BRANCHES. ExitWorktreeTool.execute returns to the main worktree. It refuses with UNCOMMITTED_CHANGES and suggestions when there are uncommitted changes, unless force or stash_changes is set, and it supports remove and prune. Git calls go through _run_git with a 30-second default timeout. WorktreeInfo describes a worktree and WorktreeToolError carries a code, details and suggestions into a failed ToolResult. Neither tool raises.
At a glance
| Source | src/orchestrata_ultimate/tools/worktree_tools.py |
| Lines of code | 971 |
| Classes | 5 |
| Functions | 15 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.tools.base |
| Imported by | orchestrata_ultimate.agents.tool_registry, orchestrata_ultimate.tools |
| Tests that import it | tests/tools/test_worktree_tools.py |
Class diagram
Dependency diagram
API
Constants
| Name | Type | Value |
|---|---|---|
DEFAULT_PROTECTED_BRANCHES |
tuple[str, ...] |
('main', 'master', 'develop', 'HEAD') |
_SESSION_STATE |
dict[str, _SessionWorktreeState] |
{} |
Classes
WorktreeToolError
Bases: Exception · Line 69
Raised for any spec.md ERR-00N condition; knows how to become a ToolResult.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(code: str, message: str, *, details: dict[str, Any] | None = None, suggestions: list[str] | None = None) -> None |
|
to_result |
to_result(tool_name: str) -> ToolResult |
Render this error as a failed :class:ToolResult (spec.md ErrorResponse). |
WorktreeInfo (dataclass)
Decorators: traces(spec='064', reqs=['REQ-064-005']), dataclass(slots=True, frozen=True) · Line 110
A single Git worktree's metadata (spec.md WorktreeInfo schema).
| Attribute | Type | Default |
|---|---|---|
path |
str |
|
branch |
str |
|
head |
str |
|
is_main |
bool |
False |
is_bare |
bool |
False |
locked |
bool |
False |
lock_reason |
str | None |
None |
| Method | Signature | Summary |
|---|---|---|
to_dict |
to_dict() -> dict[str, Any] |
_SessionWorktreeState (dataclass)
Decorators: dataclass(slots=True) · Line 129
Per-session worktree context tracked in-process (see module docstring).
| Attribute | Type | Default |
|---|---|---|
main_worktree_path |
str |
|
worktree_path |
str |
|
branch |
str |
|
base_ref |
str | None |
|
entered_at |
float |
EnterWorktreeTool
Bases: BaseTool · Decorators: traces(spec='064', story='US-064-001', reqs=['REQ-064-001', 'REQ-064-002', 'REQ-064-003', 'REQ-064-004', 'REQ-064-005',… · Line 445
Enter an existing Git worktree, create+enter a new one, or list all worktrees.
| Method | Signature | Summary |
|---|---|---|
metadata property |
metadata() -> ToolMetadata |
|
execute |
async execute(arguments: dict[str, Any]) -> ToolResult |
Enter/create a worktree, or list all worktrees (never raises). |
_enter_existing staticmethod |
_enter_existing(worktrees: list[WorktreeInfo], resolved: Path, force: bool) -> tuple[WorktreeInfo, bool] |
|
_create staticmethod |
_create(main_root: Path, worktrees: list[WorktreeInfo], resolved: Path, branch: str | None, create_branch: bool, start_point: str | None, force: bool, protected: Sequence[str]) -> tuple[WorktreeInfo, bool] |
ExitWorktreeTool
Bases: BaseTool · Decorators: traces(spec='064', story='US-064-003', reqs=['REQ-064-023', 'REQ-064-024', 'REQ-064-025', 'REQ-064-026', 'REQ-064-027',… · Line 786
Exit the current Git worktree and return to the main repository.
| Method | Signature | Summary |
|---|---|---|
metadata property |
metadata() -> ToolMetadata |
|
execute |
async execute(arguments: dict[str, Any]) -> ToolResult |
Exit the current worktree back to main (never raises). |
_list_with_fallback staticmethod |
_list_with_fallback(session_id: str, anchor: Path) -> list[WorktreeInfo] |
List worktrees from anchor, falling back to the session's recorded main worktree path if anchor itself is no longer accessible. |
_anchor_is_main staticmethod |
_anchor_is_main(anchor: Path, main_root: Path) -> bool |
Functions
| Function | Signature | Summary |
|---|---|---|
_set_state |
_set_state(session_id: str, main_root: Path, info: WorktreeInfo, base_ref: str | None) -> None |
|
_clear_state |
_clear_state(session_id: str) -> None |
|
_get_state traces |
_get_state(session_id: str) -> _SessionWorktreeState | None |
|
_resolve_anchor |
_resolve_anchor(session_id: str, cwd_override: str | None) -> Path |
Determine the directory git commands should run from for this call. |
_run_git |
_run_git(args: list[str], cwd: Path, timeout: float = 30.0) -> subprocess.CompletedProcess[str] |
Run git <args> in cwd, never raising — failures surface via returncode. |
_split_porcelain_blocks traces |
_split_porcelain_blocks(output: str) -> list[dict[str, str | bool]] |
Split git worktree list --porcelain output into per-worktree blocks. |
_block_to_info traces |
_block_to_info(block: dict[str, str | bool], is_main: bool) -> WorktreeInfo |
|
list_worktrees traces |
list_worktrees(anchor: Path) -> list[WorktreeInfo] |
List every worktree of the repository anchor belongs to. |
_match_current |
_match_current(worktrees: list[WorktreeInfo], anchor: Path) -> WorktreeInfo | None |
Find the worktree entry that anchor is inside (root or nested dir). |
_uncommitted_changes |
_uncommitted_changes(worktree_path: Path) -> list[str] |
Return non-empty git status --porcelain lines (empty == clean tree). |
_require_str |
_require_str(arguments: dict[str, Any], key: str) -> str |
|
_resolve_path traces |
_resolve_path(worktree_path: str, anchor: Path) -> Path |
Resolve worktree_path (absolute or relative to anchor) to an absolute path. |
_check_bounds traces |
_check_bounds(resolved: Path, main_root: Path) -> None |
Reject worktree paths outside the repository's parent directory (spec.md 12.3.1). |
_validate_branch_name traces |
_validate_branch_name(branch: str) -> None |
|
_is_protected traces |
_is_protected(branch: str, patterns: Sequence[str]) -> bool |
Match branch against protected-branch glob patterns (spec.md 12.1.3). |
Source
"""Git worktree lifecycle tools — ``EnterWorktreeTool`` + ``ExitWorktreeTool``.
Spec: SPEC-064 (Git Worktree Tools). Provides real ``git worktree`` subprocess
plumbing so a caller can enter/create an isolated worktree for parallel
development and safely exit back to the main repository, with protected-branch
enforcement, dirty-state detection, and abandoned-worktree cleanup.
**Fork from spec.md (reported to the orchestrator, not silently guessed):**
spec.md's Technical Design / Dependencies sections assume a harness runtime
this codebase does not have — ``orchestrata.session.state`` (session-owned
CWD), ``orchestrata.config`` (config-driven protected-branch list), and
``orchestrata.logging`` (structured audit logging) are all references to the
*old* pre-port package layout, not modules that exist in
``orchestrata_ultimate``. There is no execution context here that calls tools
with an ambient "current session directory". Per the build brief, this module
implements the tools as **self-contained state**: a per-process, in-tool
``session_id -> state`` dict (:data:`_SESSION_STATE`, guarded by
:data:`_STATE_LOCK` for free-threaded safety) that the caller addresses with
its own ``session_id`` string. Each tool call also accepts an optional
``cwd`` override so callers (and tests) can point at any repository without
relying on the process's ambient working directory.
Two ``BaseTool``\\s per the SPEC-062 framework contract:
* :class:`EnterWorktreeTool` (``enter_worktree``) — enter an existing worktree,
create+enter a new one (with protected-branch enforcement), or (via
``list_only=true``) list all worktrees (folds spec.md's ``ListWorktreesTool``
into this tool rather than adding a third registrable tool, since the build
brief scopes new files/tool names to exactly these two).
* :class:`ExitWorktreeTool` (``exit_worktree``) — return to the main
repository, with uncommitted-change detection (refuse/stash/force), and
optional ``remove``/``prune`` to tear down or garbage-collect worktrees
(the brief's "remove"/"cleanup of abandoned worktrees" asks; these two
behaviours go beyond spec.md's literal FR table and are tracked under
REQ-064-EXT-* in the requirements checklist rather than a spec FR id).
"""
from __future__ import annotations
import fnmatch
import json
import re
import subprocess
import threading
import time
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.tools.base import (
BaseTool,
ToolCategory,
ToolMetadata,
ToolPriority,
ToolResult,
)
# ── constants ──────────────────────────────────────────────────────────
DEFAULT_PROTECTED_BRANCHES: tuple[str, ...] = ("main", "master", "develop", "HEAD")
_BRANCH_NAME_RE = re.compile(r"^[a-zA-Z0-9_./-]+$")
# ── error model ────────────────────────────────────────────────────────
class WorktreeToolError(Exception):
"""Raised for any spec.md ERR-00N condition; knows how to become a ToolResult."""
def __init__(
self,
code: str,
message: str,
*,
details: dict[str, Any] | None = None,
suggestions: list[str] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.details = details
self.suggestions = suggestions or []
def to_result(self, tool_name: str) -> ToolResult:
"""Render this error as a failed :class:`ToolResult` (spec.md ErrorResponse)."""
payload: dict[str, Any] = {
"success": False,
"error": self.message,
"code": self.code,
}
if self.details is not None:
payload["details"] = self.details
if self.suggestions:
payload["recovery_suggestions"] = self.suggestions
return ToolResult(
tool_name=tool_name,
success=False,
output=json.dumps(payload),
error=f"{self.code}: {self.message}",
)
# ── data model (spec.md 3.1 WorktreeInfo) ───────────────────────────────
@traces(spec="064", reqs=["REQ-064-005"])
@dataclass(slots=True, frozen=True)
class WorktreeInfo:
"""A single Git worktree's metadata (spec.md WorktreeInfo schema)."""
path: str
branch: str
head: str
is_main: bool = False
is_bare: bool = False
locked: bool = False
lock_reason: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
# ── self-contained session state (the documented spec.md fork) ─────────
@dataclass(slots=True)
class _SessionWorktreeState:
"""Per-session worktree context tracked in-process (see module docstring)."""
main_worktree_path: str
worktree_path: str
branch: str
base_ref: str | None
entered_at: float
_STATE_LOCK = threading.Lock()
_SESSION_STATE: dict[str, _SessionWorktreeState] = {}
def _set_state(
session_id: str, main_root: Path, info: WorktreeInfo, base_ref: str | None
) -> None:
with _STATE_LOCK:
_SESSION_STATE[session_id] = _SessionWorktreeState(
main_worktree_path=str(main_root),
worktree_path=info.path,
branch=info.branch,
base_ref=base_ref,
entered_at=time.time(),
)
def _clear_state(session_id: str) -> None:
with _STATE_LOCK:
_SESSION_STATE.pop(session_id, None)
@traces(spec="064", reqs=["REQ-064-004"])
def _get_state(session_id: str) -> _SessionWorktreeState | None:
with _STATE_LOCK:
return _SESSION_STATE.get(session_id)
def _resolve_anchor(session_id: str, cwd_override: str | None) -> Path:
"""Determine the directory git commands should run from for this call.
Precedence: an explicit ``cwd`` override, then this session's previously
entered worktree, then the process's own CWD.
"""
if cwd_override:
return Path(cwd_override).resolve()
state = _get_state(session_id)
if state is not None:
return Path(state.worktree_path)
return Path.cwd()
# ── git plumbing ─────────────────────────────────────────────────────────
def _run_git(
args: list[str], cwd: Path, timeout: float = 30.0
) -> subprocess.CompletedProcess[str]:
"""Run ``git <args>`` in ``cwd``, never raising — failures surface via returncode."""
try:
return subprocess.run(
["git", *args],
cwd=str(cwd),
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
return subprocess.CompletedProcess(
args=["git", *args], returncode=128, stdout="", stderr=str(exc)
)
@traces(spec="064", reqs=["REQ-064-046"])
def _split_porcelain_blocks(output: str) -> list[dict[str, str | bool]]:
"""Split ``git worktree list --porcelain`` output into per-worktree blocks."""
blocks: list[dict[str, str | bool]] = []
current: dict[str, str | bool] = {}
for raw_line in output.splitlines():
line = raw_line.rstrip()
if not line:
if current:
blocks.append(current)
current = {}
continue
if line.startswith("worktree "):
current["worktree"] = line[len("worktree ") :]
elif line.startswith("HEAD "):
current["head"] = line[len("HEAD ") :]
elif line.startswith("branch "):
current["branch"] = line[len("branch ") :]
elif line == "detached":
current["detached"] = True
elif line == "bare":
current["bare"] = True
elif line == "locked" or line.startswith("locked "):
reason = line[len("locked") :].strip()
current["locked"] = reason or True
if current:
blocks.append(current)
return blocks
@traces(spec="064", reqs=["REQ-064-047", "REQ-064-018"])
def _block_to_info(block: dict[str, str | bool], is_main: bool) -> WorktreeInfo:
branch_ref = block.get("branch")
branch = (
branch_ref.removeprefix("refs/heads/")
if isinstance(branch_ref, str)
else "HEAD"
)
locked_val = block.get("locked", False)
return WorktreeInfo(
path=str(block.get("worktree", "")),
branch=branch,
head=str(block.get("head", "")),
is_main=is_main,
is_bare=bool(block.get("bare", False)),
locked=bool(locked_val),
lock_reason=locked_val if isinstance(locked_val, str) else None,
)
@traces(
spec="064",
story="US-064-004",
reqs=[
"REQ-064-015",
"REQ-064-016",
"REQ-064-017",
"REQ-064-045",
"REQ-064-046",
"REQ-064-047",
"REQ-064-048",
"REQ-064-049",
],
)
def list_worktrees(anchor: Path) -> list[WorktreeInfo]:
"""List every worktree of the repository ``anchor`` belongs to.
Git always lists the main worktree first, so ``worktrees[0].is_main`` is
always ``True`` when the list is non-empty. Raises
:class:`WorktreeToolError` (``NOT_A_GIT_REPO``) if ``anchor`` is not
inside a git working tree.
"""
proc = _run_git(["worktree", "list", "--porcelain"], cwd=anchor)
if proc.returncode != 0:
raise WorktreeToolError(
"NOT_A_GIT_REPO",
f"Not a git repository: {anchor}",
details={"path": str(anchor), "stderr": proc.stderr.strip()},
suggestions=[
"Navigate to a git repository directory",
"Initialize a new git repository if needed: git init",
],
)
blocks = _split_porcelain_blocks(proc.stdout)
return [_block_to_info(block, idx == 0) for idx, block in enumerate(blocks)]
def _match_current(worktrees: list[WorktreeInfo], anchor: Path) -> WorktreeInfo | None:
"""Find the worktree entry that ``anchor`` is inside (root or nested dir)."""
try:
anchor_resolved = anchor.resolve()
except OSError:
return None
for info in worktrees:
wt_path = Path(info.path)
wt_resolved = wt_path.resolve() if wt_path.exists() else wt_path
if anchor_resolved == wt_resolved:
return info
if wt_resolved.exists():
try:
if anchor_resolved.is_relative_to(wt_resolved):
return info
except ValueError:
continue
return None
def _uncommitted_changes(worktree_path: Path) -> list[str]:
"""Return non-empty ``git status --porcelain`` lines (empty == clean tree)."""
if not worktree_path.exists():
return []
proc = _run_git(["status", "--porcelain"], cwd=worktree_path)
if proc.returncode != 0:
return []
return [line for line in proc.stdout.splitlines() if line.strip()]
# ── shared validation helpers ────────────────────────────────────────────
def _require_str(arguments: dict[str, Any], key: str) -> str:
value = arguments.get(key)
if not isinstance(value, str) or not value:
raise WorktreeToolError(
"MISSING_PARAMETER",
f"Missing required parameter: '{key}'",
details={"parameter": key},
)
return value
@traces(spec="064", story="US-064-002", reqs=["REQ-064-014"])
def _resolve_path(worktree_path: str, anchor: Path) -> Path:
"""Resolve ``worktree_path`` (absolute or relative to ``anchor``) to an absolute path."""
if "\x00" in worktree_path or not worktree_path.strip():
raise WorktreeToolError(
"INVALID_PATH",
f"Invalid path format: {worktree_path!r}",
details={"provided_path": worktree_path},
suggestions=[
"Provide a valid absolute or relative path",
"Avoid special characters: null bytes, newlines",
],
)
try:
candidate = Path(worktree_path)
target = candidate if candidate.is_absolute() else (anchor / candidate)
return target.resolve()
except (OSError, ValueError) as exc:
raise WorktreeToolError(
"INVALID_PATH",
f"Invalid path format: {exc}",
details={"provided_path": worktree_path},
) from exc
@traces(spec="064", story="US-064-002", reqs=["REQ-064-014"])
def _check_bounds(resolved: Path, main_root: Path) -> None:
"""Reject worktree paths outside the repository's parent directory (spec.md 12.3.1)."""
allowed_root = main_root.parent
try:
resolved.relative_to(allowed_root)
except ValueError as exc:
raise WorktreeToolError(
"PATH_OUTSIDE_REPO",
"Worktree path is outside repository bounds",
details={"provided_path": str(resolved), "repo_root": str(main_root)},
suggestions=[
f"Create worktree within repository: {main_root}/worktrees/feature-name",
"Use a path relative to the repository root",
],
) from exc
@traces(spec="064", reqs=["REQ-064-043"])
def _validate_branch_name(branch: str) -> None:
if not branch or not _BRANCH_NAME_RE.match(branch) or ".." in branch:
raise WorktreeToolError(
"INVALID_BRANCH_NAME",
f"Branch name contains invalid characters: {branch!r}",
details={"branch": branch},
suggestions=[
"Provide a valid branch name (letters, digits, '/', '_', '-', '.')"
],
)
@traces(
spec="064",
story="US-064-005",
reqs=["REQ-064-SEC-001", "REQ-064-SEC-002", "REQ-064-SEC-003"],
)
def _is_protected(branch: str, patterns: Sequence[str]) -> bool:
"""Match ``branch`` against protected-branch glob patterns (spec.md 12.1.3)."""
return any(fnmatch.fnmatchcase(branch, pattern) for pattern in patterns)
# ── EnterWorktreeTool ────────────────────────────────────────────────────
@traces(
spec="064",
story="US-064-001",
reqs=[
"REQ-064-001",
"REQ-064-002",
"REQ-064-003",
"REQ-064-004",
"REQ-064-005",
"REQ-064-006",
"REQ-064-007",
"REQ-064-008",
"REQ-064-009",
"REQ-064-010",
"REQ-064-011",
"REQ-064-012",
"REQ-064-013",
"REQ-064-014",
"REQ-064-015",
"REQ-064-016",
"REQ-064-017",
"REQ-064-018",
"REQ-064-019",
"REQ-064-020",
"REQ-064-021",
"REQ-064-036",
"REQ-064-037",
"REQ-064-038",
"REQ-064-041",
"REQ-064-042",
"REQ-064-043",
"REQ-064-044",
"REQ-064-045",
"REQ-064-046",
"REQ-064-047",
"REQ-064-048",
"REQ-064-049",
"REQ-064-SEC-001",
"REQ-064-SEC-002",
"REQ-064-SEC-003",
],
)
class EnterWorktreeTool(BaseTool):
"""Enter an existing Git worktree, create+enter a new one, or list all worktrees.
See the module docstring for the self-contained-session-state fork from
spec.md's assumed harness runtime.
"""
_METADATA = ToolMetadata(
name="enter_worktree",
description=(
"Enter an existing Git worktree or create a new one for isolated "
"parallel development. Pass list_only=true to list all worktrees "
"of the repository instead of entering one."
),
category=ToolCategory.FILE,
parameters={
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Caller-supplied id used to track this session's active worktree.",
},
"worktree_path": {
"type": "string",
"description": "Path to the worktree (absolute, or relative to cwd/the session's current worktree). Required unless list_only=true.",
},
"branch": {
"type": ["string", "null"],
"description": "Branch to check out (or create). Required if create=true.",
"default": None,
},
"create": {
"type": "boolean",
"description": "Create the worktree if it doesn't exist.",
"default": False,
},
"create_branch": {
"type": "boolean",
"description": "Create the branch if it doesn't already exist. Requires create=true.",
"default": False,
},
"start_point": {
"type": ["string", "null"],
"description": "Commit-ish to start a newly-created branch from. Defaults to HEAD.",
"default": None,
},
"force": {
"type": "boolean",
"description": "Force creation/entry even if the target path exists or is locked.",
"default": False,
},
"cwd": {
"type": ["string", "null"],
"description": "Anchor directory used to locate the git repository. Defaults to the session's current worktree, else the process CWD.",
"default": None,
},
"protected_branches": {
"type": ["array", "null"],
"items": {"type": "string"},
"description": "Glob patterns for branches that may not have worktrees created on them. Defaults to main/master/develop/HEAD.",
"default": None,
},
"list_only": {
"type": "boolean",
"description": "If true, ignore worktree_path/create and return the list of all worktrees.",
"default": False,
},
},
"required": ["session_id"],
"additionalProperties": False,
},
priority=ToolPriority.P1,
)
@property
def metadata(self) -> ToolMetadata:
return self._METADATA
async def execute(self, arguments: dict[str, Any]) -> ToolResult:
"""Enter/create a worktree, or list all worktrees (never raises)."""
try:
session_id = _require_str(arguments, "session_id")
anchor = _resolve_anchor(session_id, arguments.get("cwd"))
worktrees = list_worktrees(anchor)
main_root = Path(worktrees[0].path)
if bool(arguments.get("list_only", False)):
payload = {
"success": True,
"worktrees": [w.to_dict() for w in worktrees],
"count": len(worktrees),
"message": f"Found {len(worktrees)} worktree(s)",
}
return ToolResult(
tool_name=self.metadata.name,
success=True,
output=json.dumps(payload),
)
worktree_path_arg = _require_str(arguments, "worktree_path")
resolved = _resolve_path(worktree_path_arg, anchor)
_check_bounds(resolved, main_root)
branch = arguments.get("branch")
create = bool(arguments.get("create", False))
create_branch = bool(arguments.get("create_branch", False))
start_point = arguments.get("start_point")
force = bool(arguments.get("force", False))
protected = arguments.get("protected_branches") or list(
DEFAULT_PROTECTED_BRANCHES
)
previous = _match_current(worktrees, anchor)
if create:
info, created = self._create(
main_root,
worktrees,
resolved,
branch,
create_branch,
start_point,
force,
protected,
)
else:
info, created = self._enter_existing(worktrees, resolved, force)
_set_state(session_id, main_root, info, start_point)
payload = {
"success": True,
"worktree": info.to_dict(),
"previous_worktree": previous.to_dict() if previous else None,
"created": created,
"message": (
f"Created and entered worktree for {info.branch}"
if created
else f"Entered existing worktree for {info.branch}"
),
}
return ToolResult(
tool_name=self.metadata.name, success=True, output=json.dumps(payload)
)
except WorktreeToolError as exc:
return exc.to_result(self.metadata.name)
except Exception as exc: # noqa: BLE001 - never propagate; report as ToolResult
return ToolResult(
tool_name=self.metadata.name, success=False, output="", error=str(exc)
)
@staticmethod
@traces(
spec="064",
story="US-064-001",
reqs=["REQ-064-002", "REQ-064-003", "REQ-064-016", "REQ-064-018"],
)
def _enter_existing(
worktrees: list[WorktreeInfo], resolved: Path, force: bool
) -> tuple[WorktreeInfo, bool]:
if not resolved.exists():
raise WorktreeToolError(
"WORKTREE_NOT_FOUND",
f"Worktree not found at path: {resolved}",
details={"requested_path": str(resolved)},
suggestions=[
"Create the worktree first: enter_worktree with create=true",
"List available worktrees with list_only=true",
],
)
match = next((w for w in worktrees if Path(w.path).resolve() == resolved), None)
if match is None:
raise WorktreeToolError(
"WORKTREE_NOT_REGISTERED",
f"Path exists but is not a registered git worktree: {resolved}",
details={"path": str(resolved)},
suggestions=[
"Use git worktree add to create a proper worktree",
"This may be a regular directory that was accidentally created",
],
)
if match.locked and not force:
raise WorktreeToolError(
"WORKTREE_LOCKED",
f"Worktree is locked: {resolved}",
details={"path": str(resolved), "lock_reason": match.lock_reason},
suggestions=[
"Unlock with: git worktree unlock <path>",
"Pass force=true to enter anyway",
],
)
return match, False
@staticmethod
@traces(
spec="064",
story="US-064-002",
reqs=[
"REQ-064-036",
"REQ-064-037",
"REQ-064-038",
"REQ-064-041",
"REQ-064-042",
"REQ-064-043",
"REQ-064-SEC-001",
"REQ-064-SEC-002",
"REQ-064-SEC-003",
],
)
def _create(
main_root: Path,
worktrees: list[WorktreeInfo],
resolved: Path,
branch: str | None,
create_branch: bool,
start_point: str | None,
force: bool,
protected: Sequence[str],
) -> tuple[WorktreeInfo, bool]:
if not branch:
raise WorktreeToolError(
"BRANCH_REQUIRED",
"branch is required when create=true",
details={},
)
_validate_branch_name(branch)
if _is_protected(branch, protected):
raise WorktreeToolError(
"PROTECTED_BRANCH",
f"Cannot create worktree on protected branch: '{branch}'",
details={
"branch": branch,
"protection_reason": "Protected branches must remain clean; work in a feature branch instead.",
},
suggestions=[
"Create a feature branch instead: git checkout -b feature/your-feature",
"Work on the feature branch in a worktree",
],
)
existing_by_path = next(
(w for w in worktrees if Path(w.path).resolve() == resolved), None
)
if resolved.exists() and not force:
raise WorktreeToolError(
"WORKTREE_ALREADY_EXISTS",
f"Worktree already exists at: {resolved}",
details={"existing_path": str(resolved)},
suggestions=[
"Use enter_worktree without create=true to enter the existing worktree",
"Choose a different path for the new worktree",
],
)
existing_by_branch = next((w for w in worktrees if w.branch == branch), None)
if existing_by_branch is not None and (
existing_by_path is None
or Path(existing_by_branch.path).resolve() != resolved
):
raise WorktreeToolError(
"BRANCH_ALREADY_HAS_WORKTREE",
f"Branch '{branch}' already has a worktree",
details={
"branch": branch,
"existing_worktree": existing_by_branch.path,
},
suggestions=[
f"Enter the existing worktree: enter_worktree(worktree_path='{existing_by_branch.path}')",
"Delete the existing worktree first if you want to create a new one",
],
)
if not create_branch:
ref_check = _run_git(
["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
cwd=main_root,
)
if ref_check.returncode != 0:
raise WorktreeToolError(
"BRANCH_NOT_FOUND",
f"Branch '{branch}' does not exist",
details={"branch": branch},
suggestions=[
"Enable create_branch=true to create the branch",
"Specify a valid existing branch name",
"Check branch name for typos",
],
)
cmd = ["worktree", "add"]
if force:
cmd.append("--force")
if create_branch:
cmd += ["-b", branch, str(resolved)]
if start_point:
cmd.append(start_point)
else:
cmd += [str(resolved), branch]
result = _run_git(cmd, cwd=main_root, timeout=60.0)
if result.returncode != 0:
raise WorktreeToolError(
"GIT_COMMAND_FAILED",
f"git worktree add failed: {result.stderr.strip()}",
details={"command": " ".join(cmd), "stderr": result.stderr.strip()},
)
refreshed = list_worktrees(main_root)
info = next((w for w in refreshed if Path(w.path).resolve() == resolved), None)
if info is None: # pragma: no cover - defensive; git reported success
raise WorktreeToolError(
"GIT_COMMAND_FAILED",
"git worktree add reported success but the worktree was not found afterward",
details={"path": str(resolved)},
)
return info, True
# ── ExitWorktreeTool ─────────────────────────────────────────────────────
@traces(
spec="064",
story="US-064-003",
reqs=[
"REQ-064-023",
"REQ-064-024",
"REQ-064-025",
"REQ-064-026",
"REQ-064-027",
"REQ-064-028",
"REQ-064-029",
"REQ-064-030",
"REQ-064-031",
"REQ-064-032",
"REQ-064-033",
"REQ-064-035",
"REQ-064-EXT-001",
"REQ-064-EXT-002",
],
)
class ExitWorktreeTool(BaseTool):
"""Exit the current Git worktree and return to the main repository.
See the module docstring for the self-contained-session-state fork from
spec.md's assumed harness runtime.
"""
_METADATA = ToolMetadata(
name="exit_worktree",
description=(
"Exit the current Git worktree and return to the main repository. "
"Optionally stash or force through uncommitted changes, remove "
"the worktree being exited, and prune abandoned worktree state."
),
category=ToolCategory.FILE,
parameters={
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "The same session_id previously passed to enter_worktree.",
},
"force": {
"type": "boolean",
"description": "Exit even with uncommitted changes (changes remain in the worktree).",
"default": False,
},
"stash_changes": {
"type": "boolean",
"description": "Automatically stash uncommitted changes before exiting.",
"default": False,
},
"remove": {
"type": "boolean",
"description": "Remove (delete) the exited worktree via 'git worktree remove'.",
"default": False,
},
"prune": {
"type": "boolean",
"description": "Prune abandoned worktree administrative state via 'git worktree prune'.",
"default": False,
},
"cwd": {
"type": ["string", "null"],
"description": "Anchor directory used to locate the git repository. Defaults to the session's current worktree, else the process CWD.",
"default": None,
},
},
"required": ["session_id"],
"additionalProperties": False,
},
priority=ToolPriority.P1,
)
@property
def metadata(self) -> ToolMetadata:
return self._METADATA
async def execute(self, arguments: dict[str, Any]) -> ToolResult:
"""Exit the current worktree back to main (never raises)."""
try:
session_id = _require_str(arguments, "session_id")
anchor = _resolve_anchor(session_id, arguments.get("cwd"))
worktrees = self._list_with_fallback(session_id, anchor)
main_info = worktrees[0]
main_root = Path(main_info.path)
current = _match_current(worktrees, anchor)
already_in_main = (
current.is_main
if current is not None
else self._anchor_is_main(anchor, main_root)
)
if already_in_main:
_clear_state(session_id)
if bool(arguments.get("prune", False)):
_run_git(["worktree", "prune"], cwd=main_root)
payload = {
"success": True,
"main_worktree": main_info.to_dict(),
"previous_worktree": None,
"message": "Already in main worktree",
"stash_result": None,
}
return ToolResult(
tool_name=self.metadata.name,
success=True,
output=json.dumps(payload),
)
worktree_path = Path(current.path) if current is not None else anchor
previous_dict = current.to_dict() if current is not None else None
force = bool(arguments.get("force", False))
stash_changes = bool(arguments.get("stash_changes", False))
stash_result: str | None = None
changes = _uncommitted_changes(worktree_path)
if changes and not force and not stash_changes:
raise WorktreeToolError(
"UNCOMMITTED_CHANGES",
"Cannot exit worktree with uncommitted changes",
details={"changed_files": len(changes), "changes": changes[:10]},
suggestions=[
"Commit your changes: git add . && git commit",
"Stash changes: exit_worktree with stash_changes=true",
"Force exit: exit_worktree with force=true (changes will remain)",
],
)
if changes and stash_changes:
stash = _run_git(
["stash", "push", "-u", "-m", "exit_worktree auto-stash"],
cwd=worktree_path,
)
if stash.returncode != 0:
raise WorktreeToolError(
"GIT_COMMAND_FAILED",
f"git stash failed: {stash.stderr.strip()}",
details={"stderr": stash.stderr.strip()},
)
stash_result = "stashed"
if bool(arguments.get("remove", False)):
remove_cmd = ["worktree", "remove"]
if force or stash_result == "stashed":
remove_cmd.append("--force")
remove_cmd.append(str(worktree_path))
removed = _run_git(remove_cmd, cwd=main_root)
if removed.returncode != 0:
raise WorktreeToolError(
"GIT_COMMAND_FAILED",
f"git worktree remove failed: {removed.stderr.strip()}",
details={"stderr": removed.stderr.strip()},
)
if bool(arguments.get("prune", False)):
_run_git(["worktree", "prune"], cwd=main_root)
_clear_state(session_id)
payload = {
"success": True,
"main_worktree": main_info.to_dict(),
"previous_worktree": previous_dict,
"message": f"Exited worktree, returned to main repository at {main_root}",
"stash_result": stash_result,
}
return ToolResult(
tool_name=self.metadata.name, success=True, output=json.dumps(payload)
)
except WorktreeToolError as exc:
return exc.to_result(self.metadata.name)
except Exception as exc: # noqa: BLE001 - never propagate; report as ToolResult
return ToolResult(
tool_name=self.metadata.name, success=False, output="", error=str(exc)
)
@staticmethod
@traces(spec="064", story="US-064-003", reqs=["REQ-064-023", "REQ-064-032"])
def _list_with_fallback(session_id: str, anchor: Path) -> list[WorktreeInfo]:
"""List worktrees from ``anchor``, falling back to the session's recorded
main worktree path if ``anchor`` itself is no longer accessible."""
try:
return list_worktrees(anchor)
except WorktreeToolError as exc:
state = _get_state(session_id)
if state is None:
raise
try:
return list_worktrees(Path(state.main_worktree_path))
except WorktreeToolError:
raise WorktreeToolError(
"MAIN_WORKTREE_UNAVAILABLE",
"Cannot determine or access the main worktree",
details={"session_id": session_id},
suggestions=["Verify the main repository still exists on disk"],
) from exc
@staticmethod
def _anchor_is_main(anchor: Path, main_root: Path) -> bool:
try:
return anchor.resolve() == main_root.resolve()
except OSError:
return False