worktree-for-agents 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GUIDE.md +45 -0
- package/README.md +180 -2
- package/docs/installation.md +214 -0
- package/docs/mcp-contract.md +78 -0
- package/mcp-server.mjs +54 -0
- package/package.json +37 -4
- package/scripts/postinstall.mjs +11 -0
- package/scripts/python.mjs +69 -0
- package/setup.mjs +208 -0
- package/worktree.mjs +47 -0
- package/worktree.sh +18 -0
- package/worktree_manager/__init__.py +2 -0
- package/worktree_manager/__main__.py +2 -0
- package/worktree_manager/backup_store.py +16 -0
- package/worktree_manager/cli.py +137 -0
- package/worktree_manager/commit_message.py +63 -0
- package/worktree_manager/configuration.py +61 -0
- package/worktree_manager/git_repository.py +72 -0
- package/worktree_manager/lock_manager.py +50 -0
- package/worktree_manager/mcp_contract.py +93 -0
- package/worktree_manager/mcp_operations.py +171 -0
- package/worktree_manager/mcp_server.py +61 -0
- package/worktree_manager/mcp_transport.py +5 -0
- package/worktree_manager/models.py +69 -0
- package/worktree_manager/output.py +11 -0
- package/worktree_manager/recovery_manager.py +114 -0
- package/worktree_manager/state_digest.py +40 -0
- package/worktree_manager/target_direct_adapter.py +197 -0
- package/worktree_manager/transaction_journal.py +36 -0
- package/worktree_manager/worktree_lifecycle.py +274 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""All Git subprocess interaction is centralized here."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Sequence
|
|
11
|
+
|
|
12
|
+
from .models import ManagerError
|
|
13
|
+
|
|
14
|
+
_SHA = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class GitOutput:
|
|
19
|
+
stdout: str
|
|
20
|
+
stderr: str
|
|
21
|
+
returncode: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GitRepository:
|
|
25
|
+
def __init__(self, root: str | Path):
|
|
26
|
+
self.root = Path(root).expanduser()
|
|
27
|
+
if not self.root.is_absolute() or not self.root.is_dir():
|
|
28
|
+
raise ManagerError("invalid_repository", "Repository must be an existing absolute directory", 3)
|
|
29
|
+
self.root = self.root.resolve()
|
|
30
|
+
out = self.run(("rev-parse", "--show-toplevel"), check=False)
|
|
31
|
+
if out.returncode:
|
|
32
|
+
raise ManagerError("invalid_repository", "Directory is not a Git repository", 3)
|
|
33
|
+
self.root = Path(out.stdout.strip()).resolve()
|
|
34
|
+
|
|
35
|
+
def run(self, args: Sequence[str], *, check: bool = True, timeout: float = 60) -> GitOutput:
|
|
36
|
+
if not args or any(not isinstance(arg, str) or "\x00" in arg for arg in args):
|
|
37
|
+
raise ManagerError("invalid_argument", "Invalid Git argument", 2)
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(["git", "-C", str(self.root), *args], text=True,
|
|
40
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
41
|
+
timeout=timeout, check=False)
|
|
42
|
+
except FileNotFoundError as exc:
|
|
43
|
+
raise ManagerError("dependency_unavailable", "Git executable is unavailable", 6) from exc
|
|
44
|
+
except subprocess.TimeoutExpired as exc:
|
|
45
|
+
raise ManagerError("git_timeout", "Git command timed out", 5) from exc
|
|
46
|
+
output = GitOutput(result.stdout, result.stderr, result.returncode)
|
|
47
|
+
# Test-only crash barrier: terminate this CLI process only after Git has
|
|
48
|
+
# returned successfully, leaving durable Git effects and held locks as
|
|
49
|
+
# they would be after an abrupt process loss.
|
|
50
|
+
trigger = os.environ.get("WORKTREE_MANAGER_TEST_CRASH_AFTER_GIT")
|
|
51
|
+
if result.returncode == 0 and trigger and trigger in " ".join(args):
|
|
52
|
+
os.kill(os.getpid(), signal.SIGKILL)
|
|
53
|
+
if check and result.returncode:
|
|
54
|
+
# Preserve causal stderr for diagnostics without exposing unrelated data.
|
|
55
|
+
detail = result.stderr.strip() or "Git operation failed"
|
|
56
|
+
raise ManagerError("operation_failed", detail, 5)
|
|
57
|
+
return output
|
|
58
|
+
|
|
59
|
+
def head(self) -> str:
|
|
60
|
+
value = self.run(("rev-parse", "HEAD")).stdout.strip()
|
|
61
|
+
if not _SHA.fullmatch(value):
|
|
62
|
+
raise ManagerError("invalid_repository", "Git returned an invalid object id", 3)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
def status(self) -> str:
|
|
66
|
+
return self.run(("status", "--porcelain=v2", "--branch", "--untracked-files=all")).stdout
|
|
67
|
+
|
|
68
|
+
def worktrees(self) -> str:
|
|
69
|
+
return self.run(("worktree", "list", "--porcelain")).stdout
|
|
70
|
+
|
|
71
|
+
def index_entries(self) -> str:
|
|
72
|
+
return self.run(("ls-files", "--stage", "-z")).stdout
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Process-safe directory locks acquired in deterministic order."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os, socket, json, tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .models import ManagerError
|
|
6
|
+
class LockManager:
|
|
7
|
+
def __init__(self,lock_root: str|Path): self.root=Path(lock_root).resolve(); self.held=[]; self.barrier=os.environ.get('WORKTREE_MANAGER_TEST_LOCK_BARRIER_DIR')
|
|
8
|
+
def _mark(self, phase):
|
|
9
|
+
if not self.barrier: return
|
|
10
|
+
root=Path(self.barrier); root.mkdir(parents=True,exist_ok=True)
|
|
11
|
+
path=root/phase; fd,name=tempfile.mkstemp(prefix='.tmp-',dir=root)
|
|
12
|
+
try:
|
|
13
|
+
with os.fdopen(fd,'w') as stream: json.dump({'phase':phase,'pid':os.getpid()},stream); stream.flush(); os.fsync(stream.fileno())
|
|
14
|
+
os.replace(name,path)
|
|
15
|
+
finally:
|
|
16
|
+
if os.path.exists(name): os.unlink(name)
|
|
17
|
+
def acquire(self,names: list[str],operation: str)->None:
|
|
18
|
+
if self.held: raise ManagerError('resource_locked','Lock manager already owns locks',4)
|
|
19
|
+
self._mark('pre_lock_validation')
|
|
20
|
+
for name in sorted(set(names)):
|
|
21
|
+
if not name or name.startswith('/') or '..' in Path(name).parts:
|
|
22
|
+
self.release(); raise ManagerError('invalid_identity','Invalid lock name',3)
|
|
23
|
+
path=self.root/name; path.parent.mkdir(parents=True,exist_ok=True)
|
|
24
|
+
try: path.mkdir()
|
|
25
|
+
except FileExistsError as exc:
|
|
26
|
+
# A process crash can leave a directory lock behind. Reclaim
|
|
27
|
+
# only when its recorded local owner is demonstrably gone.
|
|
28
|
+
owner=path/'owner'
|
|
29
|
+
try:
|
|
30
|
+
pid=int(owner.read_text(encoding='utf-8').splitlines()[0])
|
|
31
|
+
os.kill(pid,0)
|
|
32
|
+
except ProcessLookupError:
|
|
33
|
+
try: owner.unlink(missing_ok=True); path.rmdir(); path.mkdir()
|
|
34
|
+
except OSError:
|
|
35
|
+
self.release(); raise ManagerError('resource_locked',f"Resource '{name}' is locked",5) from exc
|
|
36
|
+
except (OSError, ValueError, IndexError):
|
|
37
|
+
self.release(); raise ManagerError('resource_locked',f"Resource '{name}' is locked",5) from exc
|
|
38
|
+
except PermissionError:
|
|
39
|
+
self.release(); raise ManagerError('resource_locked',f"Resource '{name}' is locked",5) from exc
|
|
40
|
+
else:
|
|
41
|
+
self.release(); raise ManagerError('resource_locked',f"Resource '{name}' is locked",5) from exc
|
|
42
|
+
self.held.append(path); (path/'owner').write_text(f'{os.getpid()}\n{socket.gethostname()}\n{operation}\n',encoding='utf-8')
|
|
43
|
+
self._mark('lock_acquired')
|
|
44
|
+
def release(self)->None:
|
|
45
|
+
for path in reversed(self.held):
|
|
46
|
+
try: (path/'owner').unlink(missing_ok=True); path.rmdir()
|
|
47
|
+
except OSError: pass
|
|
48
|
+
self.held.clear()
|
|
49
|
+
def __enter__(self): return self
|
|
50
|
+
def __exit__(self,*_): self.release()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Strict, versioned JSON contracts for the worktree management service."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
|
|
6
|
+
CONTRACT_VERSION = 1
|
|
7
|
+
from . import __version__ as TOOL_VERSION
|
|
8
|
+
TOOL_NAMES = (
|
|
9
|
+
"worktree_create", "worktree_complete", "worktree_cleanup",
|
|
10
|
+
"worktree_recover", "worktree_list", "worktree_prune", "worktree_status",
|
|
11
|
+
)
|
|
12
|
+
FIELDS = {
|
|
13
|
+
"worktree_create": {"repo", "work_id", "base", "dry_run", "parent_repo", "submodule_path", "confirm", "plan_hash", "init_submodules", "prepare", "verify"},
|
|
14
|
+
"worktree_complete": {"repo", "work_id", "base", "dry_run", "authorization", "confirm", "plan_hash", "transaction_id", "preserve_dirty", "target_direct", "parent_repo", "commit_title", "commit_body"},
|
|
15
|
+
"worktree_cleanup": {"repo", "work_id", "base", "dry_run", "authorization", "confirm", "plan_hash", "discard", "discard_consent"},
|
|
16
|
+
"worktree_recover": {"repo", "transaction_id", "dry_run", "authorization", "confirm", "target_direct", "parent_repo"},
|
|
17
|
+
"worktree_list": {"repo"}, "worktree_prune": {"repo", "dry_run", "authorization", "confirm", "plan_hash"},
|
|
18
|
+
"worktree_status": {"repo", "work_id"},
|
|
19
|
+
}
|
|
20
|
+
REQUIRED = {
|
|
21
|
+
"worktree_create": {"repo", "work_id"}, "worktree_complete": {"repo", "work_id"},
|
|
22
|
+
"worktree_cleanup": {"repo", "work_id"}, "worktree_recover": {"repo"},
|
|
23
|
+
"worktree_list": {"repo"}, "worktree_prune": {"repo"},
|
|
24
|
+
"worktree_status": {"repo", "work_id"},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def validate_request(tool: str, request: dict) -> dict:
|
|
28
|
+
if tool not in FIELDS:
|
|
29
|
+
raise ValueError("unknown_tool")
|
|
30
|
+
if not isinstance(request, dict):
|
|
31
|
+
raise ValueError("invalid_request")
|
|
32
|
+
unknown = set(request) - FIELDS[tool]
|
|
33
|
+
if unknown:
|
|
34
|
+
raise ValueError("unknown_fields: " + ", ".join(sorted(unknown)))
|
|
35
|
+
if not REQUIRED[tool] <= request.keys():
|
|
36
|
+
raise ValueError("missing_required_fields: " + ", ".join(sorted(REQUIRED[tool] - request.keys())))
|
|
37
|
+
repo = request["repo"]
|
|
38
|
+
if not isinstance(repo, str) or not repo or not __import__("pathlib").Path(repo).is_absolute():
|
|
39
|
+
raise ValueError("repo_must_be_absolute")
|
|
40
|
+
for key in ("dry_run", "discard", "preserve_dirty", "discard_consent", "target_direct", "confirm", "init_submodules", "prepare", "verify"):
|
|
41
|
+
if key in request and type(request[key]) is not bool:
|
|
42
|
+
raise ValueError(f"{key}_must_be_boolean")
|
|
43
|
+
for key in ("work_id", "base", "transaction_id", "authorization", "parent_repo", "submodule_path", "commit_title", "commit_body"):
|
|
44
|
+
if key in request and not isinstance(request[key], str):
|
|
45
|
+
raise ValueError(f"{key}_must_be_string")
|
|
46
|
+
if tool in {"worktree_create", "worktree_complete", "worktree_cleanup", "worktree_status"} and not request.get("work_id"):
|
|
47
|
+
raise ValueError("work_id_required")
|
|
48
|
+
if tool == "worktree_create" and bool(request.get("parent_repo")) != bool(request.get("submodule_path")):
|
|
49
|
+
raise ValueError("target_direct_requires_parent_and_submodule_path")
|
|
50
|
+
if request.get("target_direct") and not request.get("parent_repo"):
|
|
51
|
+
raise ValueError("target_direct_requires_parent_repo")
|
|
52
|
+
normalized = dict(request)
|
|
53
|
+
has_confirm = "confirm" in normalized
|
|
54
|
+
confirm = normalized.pop("confirm", False)
|
|
55
|
+
if confirm:
|
|
56
|
+
if normalized.get("authorization") not in (None, "I_AUTHORIZE_THIS_CALL"):
|
|
57
|
+
raise ValueError("authorization_mismatch")
|
|
58
|
+
normalized["authorization"] = "I_AUTHORIZE_THIS_CALL"
|
|
59
|
+
elif has_confirm and normalized.get("authorization") == "I_AUTHORIZE_THIS_CALL":
|
|
60
|
+
raise ValueError("authorization_mismatch")
|
|
61
|
+
if "plan_hash" in normalized and (not isinstance(normalized["plan_hash"], str) or len(normalized["plan_hash"]) != 64 or any(c not in "0123456789abcdef" for c in normalized["plan_hash"])):
|
|
62
|
+
raise ValueError("plan_hash_must_be_sha256")
|
|
63
|
+
return normalized
|
|
64
|
+
|
|
65
|
+
def output_schema():
|
|
66
|
+
return {"type": "object", "required": ["contract_version", "tool_version", "operation", "repository", "result", "data", "warnings", "error", "transaction_id"], "properties": {
|
|
67
|
+
"contract_version": {"type": "integer"}, "tool_version": {"type": "string"},
|
|
68
|
+
"operation": {"type": "string"}, "repository": {"type": "string"},
|
|
69
|
+
"result": {"type": "string"}, "data": {"type": "object"}, "warnings": {"type": "array"},
|
|
70
|
+
"error": {"type": ["object", "null"]}, "transaction_id": {"type": ["string", "null"]}}, "additionalProperties": False}
|
|
71
|
+
|
|
72
|
+
def envelope(operation, repo, result="success", data=None, warnings=None, error=None, transaction_id=None):
|
|
73
|
+
return {"contract_version": CONTRACT_VERSION, "tool_version": TOOL_VERSION,
|
|
74
|
+
"operation": operation, "repository": repo, "result": result,
|
|
75
|
+
"data": data or {}, "warnings": warnings or [], "error": error,
|
|
76
|
+
"transaction_id": transaction_id}
|
|
77
|
+
|
|
78
|
+
def absolute_repository(value):
|
|
79
|
+
"""Absolute form of a requested repository, or an empty string when unknown."""
|
|
80
|
+
if not isinstance(value, str) or not value:
|
|
81
|
+
return ""
|
|
82
|
+
path = pathlib.Path(value).expanduser()
|
|
83
|
+
try:
|
|
84
|
+
return str(path.resolve())
|
|
85
|
+
except (OSError, RuntimeError):
|
|
86
|
+
return str(path.absolute())
|
|
87
|
+
|
|
88
|
+
def error_envelope(operation, repository, code, message, exit_code):
|
|
89
|
+
"""Canonical envelope for a failed operation, shared by every transport."""
|
|
90
|
+
result = "pending_intervention" if code == "intervention_required" else "failure"
|
|
91
|
+
return envelope(operation, repository or "", result, data={}, warnings=[],
|
|
92
|
+
error={"code": code, "message": message, "exit_code": exit_code},
|
|
93
|
+
transaction_id=None)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""MCP façade over the canonical Python lifecycle and recovery services."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .configuration import Configuration
|
|
4
|
+
from .git_repository import GitRepository
|
|
5
|
+
from .models import ManagerError
|
|
6
|
+
from .worktree_lifecycle import WorktreeLifecycle
|
|
7
|
+
from .recovery_manager import RecoveryManager
|
|
8
|
+
from .mcp_contract import validate_request, envelope
|
|
9
|
+
from .worktree_lifecycle import validate_work_id
|
|
10
|
+
from .target_direct_adapter import TargetDirectAdapter
|
|
11
|
+
from .state_digest import state_digest, canonical_hash
|
|
12
|
+
import hashlib
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
DESTRUCTIVE = {"worktree_complete", "worktree_cleanup", "worktree_recover", "worktree_prune"}
|
|
16
|
+
|
|
17
|
+
def call(tool, request):
|
|
18
|
+
req = validate_request(tool, request)
|
|
19
|
+
root = Path(req["repo"]).resolve(strict=True)
|
|
20
|
+
git = GitRepository(root)
|
|
21
|
+
if root != git.root:
|
|
22
|
+
raise ManagerError("invalid_repository", "Repository identity mismatch", 3)
|
|
23
|
+
req["repo"] = str(root)
|
|
24
|
+
dry = req.get("dry_run", False)
|
|
25
|
+
if tool in DESTRUCTIVE and not dry and req.get("authorization") != "I_AUTHORIZE_THIS_CALL":
|
|
26
|
+
raise ManagerError("authorization_required", "Explicit authorization for this call is required", 3)
|
|
27
|
+
lifecycle = WorktreeLifecycle(Configuration.load(root))
|
|
28
|
+
if tool in {"worktree_create", "worktree_complete", "worktree_cleanup", "worktree_prune"}:
|
|
29
|
+
if tool in {"worktree_create", "worktree_complete"}:
|
|
30
|
+
req["base"] = req.get("base") or next((b for b in ("development", "main", "trunk") if lifecycle.git.run(("show-ref", "--verify", "--quiet", f"refs/heads/{b}"), check=False).returncode == 0), "main")
|
|
31
|
+
if tool == "worktree_complete" and not req.get("transaction_id") and dry:
|
|
32
|
+
req["transaction_id"] = __import__("uuid").uuid4().hex
|
|
33
|
+
fields = ["operation", "repo", "work_id", "confirm", "transaction_id", "base", "preserve_dirty", "discard", "discard_consent", "target_direct", "parent_repo", "submodule_path", "state_digest"]
|
|
34
|
+
if tool == "worktree_complete":
|
|
35
|
+
fields = fields + ["commit_title", "commit_body"]
|
|
36
|
+
def plan_for(digest):
|
|
37
|
+
return {key: (tool if key == "operation" else digest if key == "state_digest" else bool(req.get("authorization")) if key == "confirm" else req.get(key)) for key in fields}
|
|
38
|
+
if dry:
|
|
39
|
+
digest = state_digest(root, lifecycle.state_root)
|
|
40
|
+
req["_computed_plan_hash"] = canonical_hash(plan_for(digest))
|
|
41
|
+
elif req.get("plan_hash"):
|
|
42
|
+
digest = state_digest(root, lifecycle.state_root)
|
|
43
|
+
if req["plan_hash"] != canonical_hash(plan_for(digest)):
|
|
44
|
+
raise ManagerError("plan_mismatch", "Repository state or request changed since preflight", 5)
|
|
45
|
+
if "work_id" in req:
|
|
46
|
+
validate_work_id(req["work_id"])
|
|
47
|
+
def target_adapter(identity):
|
|
48
|
+
common = Path(git.run(("rev-parse", "--git-common-dir")).stdout.strip())
|
|
49
|
+
if not common.is_absolute(): common = (root / common).resolve()
|
|
50
|
+
work_id = identity[3:] if identity.startswith("wt-") else identity
|
|
51
|
+
descriptor = common / "worktree-manager" / "target-direct" / hashlib.sha256(work_id.encode()).hexdigest() / "descriptor"
|
|
52
|
+
adapter = TargetDirectAdapter(descriptor)
|
|
53
|
+
if adapter.values["target_repo"] != str(root) or adapter.values["parent_repo"] != str(Path(req["parent_repo"]).resolve()):
|
|
54
|
+
raise ManagerError("invalid_descriptor", "Target descriptor identity does not match this request", 3)
|
|
55
|
+
return adapter
|
|
56
|
+
if tool == "worktree_create" and req.get("parent_repo"):
|
|
57
|
+
if dry: raise ManagerError("unsupported_dry_run", "Target-direct creation cannot be simulated safely", 3)
|
|
58
|
+
common = Path(git.run(("rev-parse", "--git-common-dir")).stdout.strip())
|
|
59
|
+
if not common.is_absolute(): common = (root / common).resolve()
|
|
60
|
+
descriptor = common / "worktree-manager" / "target-direct" / hashlib.sha256(req["work_id"].encode()).hexdigest() / "descriptor"
|
|
61
|
+
adapter = TargetDirectAdapter.create(descriptor, root, req["parent_repo"], req["submodule_path"], req["work_id"], req.get("base", "main"))
|
|
62
|
+
return envelope(tool, str(root), data={"target_direct": True, "worktree": adapter.values["worktree_path"], "descriptor": str(adapter.path), "phase": adapter.values["phase"]}, transaction_id=adapter.values["tx_id"])
|
|
63
|
+
if tool == "worktree_complete" and req.get("target_direct"):
|
|
64
|
+
if dry: raise ManagerError("unsupported_dry_run", "Target-direct completion cannot be simulated safely", 3)
|
|
65
|
+
data = target_adapter(req["work_id"]).complete()
|
|
66
|
+
return envelope(tool, str(root), data=data, transaction_id="wt-" + req["work_id"])
|
|
67
|
+
if tool == "worktree_recover" and req.get("target_direct"):
|
|
68
|
+
if dry: raise ManagerError("unsupported_dry_run", "Target-direct recovery cannot be simulated safely", 3)
|
|
69
|
+
tx = req.get("transaction_id")
|
|
70
|
+
if not tx: raise ManagerError("transaction_id_required", "Target-direct recovery requires transaction_id", 3)
|
|
71
|
+
data = target_adapter(tx).recover()
|
|
72
|
+
return envelope(tool, str(root), data=data, transaction_id=tx)
|
|
73
|
+
if tool == "worktree_status":
|
|
74
|
+
matches = [item for item in lifecycle.list() if item["path"] == str(lifecycle._path(req["work_id"]))]
|
|
75
|
+
journal = []
|
|
76
|
+
if lifecycle.journal.root.exists():
|
|
77
|
+
for entry in sorted(lifecycle.journal.root.iterdir()):
|
|
78
|
+
if entry.is_dir():
|
|
79
|
+
phase = lifecycle.journal.read_phase(entry.name)
|
|
80
|
+
meta_path = entry / "meta.json"
|
|
81
|
+
meta = __import__("json").loads(meta_path.read_text()) if meta_path.is_file() else {}
|
|
82
|
+
if meta.get("work_id") == req["work_id"]:
|
|
83
|
+
journal.append({"transaction_id": entry.name, "phase": phase, "metadata": meta})
|
|
84
|
+
data = {"worktree": matches[0] if matches else None, "identity_exists": bool(matches), "transactions": journal,
|
|
85
|
+
"pending_intervention": any(item["phase"] == "pending_manual" for item in journal),
|
|
86
|
+
"locks_directory": str(lifecycle.state_root / lifecycle.config.lock_dir)}
|
|
87
|
+
elif tool == "worktree_list":
|
|
88
|
+
data = {"worktrees": lifecycle.list()}
|
|
89
|
+
elif tool == "worktree_create":
|
|
90
|
+
base = req.get("base") or next((b for b in ("development", "main", "trunk") if lifecycle.git.run(("show-ref", "--verify", "--quiet", f"refs/heads/{b}"), check=False).returncode == 0), "main")
|
|
91
|
+
if dry:
|
|
92
|
+
lifecycle._base(base)
|
|
93
|
+
lifecycle._validate_create_options(bool(req.get("prepare", False)),bool(req.get("verify", False)))
|
|
94
|
+
branch, path = lifecycle._branch(req["work_id"]), lifecycle._path(req["work_id"])
|
|
95
|
+
if path.exists() or lifecycle.git.run(("show-ref", "--verify", "--quiet", f"refs/heads/{branch}"), check=False).returncode == 0:
|
|
96
|
+
raise ManagerError("operation_failed", "Worktree branch or path already exists", 5)
|
|
97
|
+
if not Path(lifecycle.config.worktrees_dir).is_absolute():
|
|
98
|
+
rel = path.relative_to(root).as_posix()
|
|
99
|
+
if lifecycle.git.run(("check-ignore", "-q", "--", rel), check=False).returncode:
|
|
100
|
+
raise ManagerError("invalid_configuration", "Worktree path is not ignored", 3)
|
|
101
|
+
data = {"planned": True, "work_id": req["work_id"], "base": base, "path": str(path), "branch": branch, "preflight": "passed", "mutations": [], "plan_hash":req.get("_computed_plan_hash"), "state_digest":digest,
|
|
102
|
+
"init_submodules": "true" if req.get("init_submodules") else "false",
|
|
103
|
+
"init_submodules_result": "planned" if req.get("init_submodules") else "not_requested",
|
|
104
|
+
"prepare": "planned" if (req.get("prepare") or lifecycle.config.prepare_enabled) else "skipped",
|
|
105
|
+
"verify": "planned" if (req.get("verify") or lifecycle.config.verify_enabled) else "skipped"}
|
|
106
|
+
else:
|
|
107
|
+
data = lifecycle.create(req["work_id"], base,
|
|
108
|
+
init_submodules=bool(req.get("init_submodules", False)),
|
|
109
|
+
prepare=bool(req.get("prepare", False)),
|
|
110
|
+
verify=bool(req.get("verify", False)))
|
|
111
|
+
elif tool == "worktree_complete":
|
|
112
|
+
if dry:
|
|
113
|
+
base=req.get("base", "main"); lifecycle._base(base)
|
|
114
|
+
path=lifecycle._path(req["work_id"])
|
|
115
|
+
if str(path) not in {item["path"] for item in lifecycle.list()} or not path.is_dir(): raise ManagerError("invalid_identity", "Worktree identity does not match registration", 3)
|
|
116
|
+
if lifecycle.git.run(("symbolic-ref", "--short", "HEAD")).stdout.strip()!=base: raise ManagerError("invalid_identity", f"Current branch must be '{base}'", 3)
|
|
117
|
+
if lifecycle.git.run(("-C", str(path), "status", "--porcelain", "--untracked-files=all")).stdout: raise ManagerError("intervention_required", "Worktree has uncommitted changes; it was retained", 5)
|
|
118
|
+
from .commit_message import compose_commit_message
|
|
119
|
+
_subject, _body = compose_commit_message(req["work_id"], req.get("commit_title"), req.get("commit_body"))
|
|
120
|
+
data = {"planned": True, "work_id": req["work_id"], "transaction_id":req.get("transaction_id"), "preflight": "passed", "mutations": ["squash_merge", "remove_worktree", "delete_branch"], "plan_hash":req.get("_computed_plan_hash"), "state_digest":digest, "commit_subject": _subject, "commit_body": _body}
|
|
121
|
+
else: data = lifecycle.complete(req["work_id"], req.get("base", "main"), req.get("preserve_dirty", False), req.get("transaction_id"), req.get("plan_hash"), req)
|
|
122
|
+
elif tool == "worktree_cleanup":
|
|
123
|
+
if req.get("discard") and req.get("discard_consent") is not True:
|
|
124
|
+
raise ManagerError("discard_consent_required", "Separate explicit discard consent is required", 3)
|
|
125
|
+
if dry:
|
|
126
|
+
path=lifecycle._path(req["work_id"])
|
|
127
|
+
if str(path) not in {item["path"] for item in lifecycle.list()} or not path.exists(): raise ManagerError("invalid_identity", "Worktree identity does not match registration", 3)
|
|
128
|
+
lifecycle._base(req.get("base", "main"))
|
|
129
|
+
changed=bool(lifecycle.git.run(("-C", str(path), "status", "--porcelain", "--untracked-files=all")).stdout)
|
|
130
|
+
if changed and not req.get("discard"): raise ManagerError("intervention_required", "Worktree has changes; discard is required", 5)
|
|
131
|
+
data = {"planned": True, "work_id": req["work_id"], "discard": req.get("discard", False), "dirty": changed, "preflight": "passed", "mutations": ["remove_worktree", "delete_branch"], "plan_hash":req.get("_computed_plan_hash"), "state_digest":digest}
|
|
132
|
+
else: data = lifecycle.cleanup(req["work_id"], req.get("discard", False), req.get("base", "main"))
|
|
133
|
+
elif tool == "worktree_prune":
|
|
134
|
+
if dry:
|
|
135
|
+
# Git's dry-run reports exactly which stale administrative records
|
|
136
|
+
# would be removed, without changing them.
|
|
137
|
+
preview = lifecycle.git.run(("worktree", "prune", "--dry-run", "--verbose"))
|
|
138
|
+
eligible = [line for line in (preview.stdout + preview.stderr).splitlines() if line.strip()]
|
|
139
|
+
worktrees = lifecycle.list()
|
|
140
|
+
locks_dir = lifecycle.state_root / lifecycle.config.lock_dir
|
|
141
|
+
locks = sorted(p.name for p in locks_dir.iterdir()) if locks_dir.is_dir() else []
|
|
142
|
+
interventions = []
|
|
143
|
+
if lifecycle.journal.root.exists():
|
|
144
|
+
for entry in sorted(lifecycle.journal.root.iterdir()):
|
|
145
|
+
if entry.is_dir() and lifecycle.journal.read_phase(entry.name) == "pending_manual":
|
|
146
|
+
interventions.append(entry.name)
|
|
147
|
+
pending = bool(eligible)
|
|
148
|
+
data = {"planned": pending, "preflight": "passed" if pending else "no_pending_work",
|
|
149
|
+
"worktrees": worktrees, "eligible_orphans": eligible, "locks": locks,
|
|
150
|
+
"pending_interventions": interventions,
|
|
151
|
+
"mutations": ["git_worktree_prune"] if pending else []}
|
|
152
|
+
if not pending:
|
|
153
|
+
return envelope(tool, str(root), "no_pending_work", data, transaction_id=None)
|
|
154
|
+
else:
|
|
155
|
+
data = lifecycle.prune()
|
|
156
|
+
if data.get("pruned", 0) == 0:
|
|
157
|
+
return envelope(tool, str(root), "no_pending_work", data, transaction_id=None)
|
|
158
|
+
else:
|
|
159
|
+
if dry:
|
|
160
|
+
entries=[]
|
|
161
|
+
if lifecycle.journal.directory.exists():
|
|
162
|
+
entries=[entry.name for entry in lifecycle.journal.directory.iterdir() if entry.is_dir()]
|
|
163
|
+
tx=req.get("transaction_id")
|
|
164
|
+
if tx and tx not in entries: raise ManagerError("invalid_identity", "Transaction does not exist", 3)
|
|
165
|
+
data = {"planned": True, "transaction_id": tx, "transactions": entries, "preflight": "passed", "mutations": ["recover_transaction"] if entries else []}
|
|
166
|
+
else:
|
|
167
|
+
git_dir = Path(git.run(("rev-parse", "--git-dir")).stdout.strip())
|
|
168
|
+
if not git_dir.is_absolute(): git_dir = (root / git_dir).resolve()
|
|
169
|
+
kind, data = RecoveryManager(git_dir / lifecycle.config.state_dir, root).recover(req.get("transaction_id"),req.get("work_id"))
|
|
170
|
+
return envelope(tool, str(root), kind, data, transaction_id=(data or {}).get("transaction_id"))
|
|
171
|
+
return envelope(tool, str(root), data=data, transaction_id=data.get("transaction_id"))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Minimal stdio JSON-RPC server exposing the worktree management tools."""
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from . import __version__
|
|
5
|
+
from .mcp_contract import TOOL_NAMES, absolute_repository, error_envelope
|
|
6
|
+
from .mcp_operations import call
|
|
7
|
+
from .models import ManagerError
|
|
8
|
+
|
|
9
|
+
MCP_INSTRUCTIONS = "Vas a implementar un cambio, ejecutar una spec o iniciar un flujo. ¿Quieres crear un worktree para hacerlo aislado?"
|
|
10
|
+
|
|
11
|
+
def schemas():
|
|
12
|
+
from .mcp_contract import FIELDS, REQUIRED, output_schema
|
|
13
|
+
props = {"repo": {"type": "string"}, "work_id": {"type": "string"},
|
|
14
|
+
"base": {"type": "string"}, "transaction_id": {"type": "string"},
|
|
15
|
+
"authorization": {"type": "string", "enum": ["I_AUTHORIZE_THIS_CALL"]},
|
|
16
|
+
"confirm": {"type":"boolean"}, "plan_hash":{"type":"string"},
|
|
17
|
+
"init_submodules":{"type":"boolean"}, "prepare":{"type":"boolean"}, "verify":{"type":"boolean"},
|
|
18
|
+
"dry_run": {"type": "boolean"}, "discard": {"type": "boolean"},
|
|
19
|
+
"discard_consent": {"type": "boolean"}, "preserve_dirty": {"type": "boolean"},
|
|
20
|
+
"commit_title": {"type": "string"}, "commit_body": {"type": "string"}}
|
|
21
|
+
props.update({"parent_repo": {"type": "string"}, "submodule_path": {"type": "string"},
|
|
22
|
+
"target_direct": {"type": "boolean"}})
|
|
23
|
+
return [{"name": name, "description": "Manage repository worktrees", "inputSchema": {
|
|
24
|
+
"type": "object", "properties": {key: props[key] for key in sorted(FIELDS[name])},
|
|
25
|
+
"required": sorted(REQUIRED[name]), "additionalProperties": False}, "outputSchema": output_schema()} for name in TOOL_NAMES]
|
|
26
|
+
|
|
27
|
+
def dispatch(message):
|
|
28
|
+
method = message.get("method")
|
|
29
|
+
params = message.get("params") or {}
|
|
30
|
+
if method == "initialize":
|
|
31
|
+
return {"protocolVersion": params.get("protocolVersion", "2024-11-05"), "capabilities": {"tools": {}}, "serverInfo": {"name": "worktree-for-agents", "version": __version__}, "instructions": MCP_INSTRUCTIONS}
|
|
32
|
+
if method == "notifications/initialized": return None
|
|
33
|
+
if method == "ping": return {}
|
|
34
|
+
if method == "tools/list": return {"tools": schemas()}
|
|
35
|
+
if method == "tools/call":
|
|
36
|
+
name = params.get("name")
|
|
37
|
+
try:
|
|
38
|
+
result = call(name, params.get("arguments", {}))
|
|
39
|
+
return {"content": [{"type": "text", "text": json.dumps(result, sort_keys=True)}], "structuredContent": result, "isError": False}
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
err = getattr(exc, "code", "invalid_request")
|
|
42
|
+
message = str(exc)
|
|
43
|
+
exit_code = getattr(exc, "exit_code", 3)
|
|
44
|
+
result = error_envelope(name or "", absolute_repository((params.get("arguments") or {}).get("repo")), err, message, exit_code)
|
|
45
|
+
return {"content": [{"type": "text", "text": json.dumps(result, sort_keys=True)}], "structuredContent": result, "isError": True}
|
|
46
|
+
raise ValueError("method_not_found")
|
|
47
|
+
|
|
48
|
+
def main():
|
|
49
|
+
for line in sys.stdin:
|
|
50
|
+
try:
|
|
51
|
+
message = json.loads(line)
|
|
52
|
+
result = dispatch(message)
|
|
53
|
+
if "id" in message and result is not None:
|
|
54
|
+
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": result}, separators=(",", ":")) + "\n")
|
|
55
|
+
sys.stdout.flush()
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
if "id" in locals().get("message", {}):
|
|
58
|
+
response = {"jsonrpc": "2.0", "id": message["id"], "error": {"code": -32601 if str(exc) == "method_not_found" else -32600, "message": str(exc)}}
|
|
59
|
+
sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n"); sys.stdout.flush()
|
|
60
|
+
else:
|
|
61
|
+
print(f"MCP request failed: {type(exc).__name__}", file=sys.stderr)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Domain values shared by the command-line and lifecycle layers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ResultKind(str, Enum):
|
|
12
|
+
SUCCESS = "success"
|
|
13
|
+
FAILURE = "failure"
|
|
14
|
+
PENDING_INTERVENTION = "pending_intervention"
|
|
15
|
+
NO_PENDING_WORK = "no_pending_work"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ManagerError(Exception):
|
|
20
|
+
code: str
|
|
21
|
+
message: str
|
|
22
|
+
exit_code: int
|
|
23
|
+
|
|
24
|
+
def __str__(self) -> str:
|
|
25
|
+
return self.message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class OperationResult:
|
|
30
|
+
operation: str
|
|
31
|
+
result: ResultKind = ResultKind.SUCCESS
|
|
32
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
warnings: list[str] = field(default_factory=list)
|
|
34
|
+
error: ManagerError | None = None
|
|
35
|
+
|
|
36
|
+
def json_document(self) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"contract_version": 1,
|
|
39
|
+
"tool_version": __version__,
|
|
40
|
+
"operation": self.operation,
|
|
41
|
+
"result": self.result.value,
|
|
42
|
+
"data": self.data,
|
|
43
|
+
"warnings": self.warnings,
|
|
44
|
+
"error": None if self.error is None else {
|
|
45
|
+
"code": self.error.code,
|
|
46
|
+
"message": self.error.message,
|
|
47
|
+
"exit_code": self.error.exit_code,
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def exit_code(self) -> int:
|
|
53
|
+
return self.error.exit_code if self.error else 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class TransactionIdentity:
|
|
58
|
+
transaction_id: str
|
|
59
|
+
repository: str
|
|
60
|
+
phase: str
|
|
61
|
+
original_head: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class TargetDirectIdentity:
|
|
66
|
+
target_repo: str
|
|
67
|
+
parent_repo: str
|
|
68
|
+
submodule_path: str
|
|
69
|
+
link_parent: str = "staged-only"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Stable human and JSON contract-v1 output."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json,sys
|
|
4
|
+
from .models import OperationResult
|
|
5
|
+
|
|
6
|
+
def emit(result: OperationResult,as_json: bool)->None:
|
|
7
|
+
if as_json: print(json.dumps(result.json_document(),ensure_ascii=False,separators=(',',':')))
|
|
8
|
+
else:
|
|
9
|
+
print(f'operation: {result.operation}\nresult: {result.result.value}')
|
|
10
|
+
for key,value in result.data.items(): print(f'{key}: {value}')
|
|
11
|
+
if result.error: print(result.error.message,file=sys.stderr)
|