243 lines
8.0 KiB
Python
243 lines
8.0 KiB
Python
"""Worktree and port management operations for isolated ADW workflows.
|
|
|
|
Provides utilities for creating and managing git worktrees under trees/<adw_id>/
|
|
and allocating unique ports for each isolated instance.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import logging
|
|
import socket
|
|
from typing import Tuple, Optional
|
|
from adw_modules.state import ADWState
|
|
|
|
|
|
def create_worktree(adw_id: str, branch_name: str, logger: logging.Logger) -> Tuple[str, Optional[str]]:
|
|
"""Create a git worktree for isolated ADW execution.
|
|
|
|
Args:
|
|
adw_id: The ADW ID for this worktree
|
|
branch_name: The branch name to create the worktree from
|
|
logger: Logger instance
|
|
|
|
Returns:
|
|
Tuple of (worktree_path, error_message)
|
|
worktree_path is the absolute path if successful, None if error
|
|
"""
|
|
# Get project root (parent of adws directory)
|
|
project_root = os.path.dirname(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
)
|
|
|
|
# Create trees directory if it doesn't exist
|
|
trees_dir = os.path.join(project_root, "trees")
|
|
os.makedirs(trees_dir, exist_ok=True)
|
|
|
|
# Construct worktree path
|
|
worktree_path = os.path.join(trees_dir, adw_id)
|
|
|
|
# Check if worktree already exists
|
|
if os.path.exists(worktree_path):
|
|
logger.warning(f"Worktree already exists at {worktree_path}")
|
|
return worktree_path, None
|
|
|
|
# First, fetch latest changes from origin
|
|
logger.info("Fetching latest changes from origin")
|
|
fetch_result = subprocess.run(
|
|
["git", "fetch", "origin"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=project_root
|
|
)
|
|
if fetch_result.returncode != 0:
|
|
logger.warning(f"Failed to fetch from origin: {fetch_result.stderr}")
|
|
|
|
# Create the worktree using git, branching from origin/main
|
|
# Use -b to create the branch as part of worktree creation
|
|
cmd = ["git", "worktree", "add", "-b", branch_name, worktree_path, "origin/main"]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, cwd=project_root)
|
|
|
|
if result.returncode != 0:
|
|
# If branch already exists, try without -b
|
|
if "already exists" in result.stderr:
|
|
cmd = ["git", "worktree", "add", worktree_path, branch_name]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, cwd=project_root)
|
|
|
|
if result.returncode != 0:
|
|
error_msg = f"Failed to create worktree: {result.stderr}"
|
|
logger.error(error_msg)
|
|
return None, error_msg
|
|
|
|
logger.info(f"Created worktree at {worktree_path} for branch {branch_name}")
|
|
return worktree_path, None
|
|
|
|
|
|
def validate_worktree(adw_id: str, state: ADWState) -> Tuple[bool, Optional[str]]:
|
|
"""Validate worktree exists in state, filesystem, and git.
|
|
|
|
Performs three-way validation to ensure consistency:
|
|
1. State has worktree_path
|
|
2. Directory exists on filesystem
|
|
3. Git knows about the worktree
|
|
|
|
Args:
|
|
adw_id: The ADW ID to validate
|
|
state: The ADW state object
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
# Check state has worktree_path
|
|
worktree_path = state.get("worktree_path")
|
|
if not worktree_path:
|
|
return False, "No worktree_path in state"
|
|
|
|
# Check directory exists
|
|
if not os.path.exists(worktree_path):
|
|
return False, f"Worktree directory not found: {worktree_path}"
|
|
|
|
# Check git knows about it
|
|
result = subprocess.run(["git", "worktree", "list"], capture_output=True, text=True)
|
|
if worktree_path not in result.stdout:
|
|
return False, "Worktree not registered with git"
|
|
|
|
return True, None
|
|
|
|
|
|
def get_worktree_path(adw_id: str) -> str:
|
|
"""Get absolute path to worktree.
|
|
|
|
Args:
|
|
adw_id: The ADW ID
|
|
|
|
Returns:
|
|
Absolute path to worktree directory
|
|
"""
|
|
project_root = os.path.dirname(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
)
|
|
return os.path.join(project_root, "trees", adw_id)
|
|
|
|
|
|
def remove_worktree(adw_id: str, logger: logging.Logger) -> Tuple[bool, Optional[str]]:
|
|
"""Remove a worktree and clean up.
|
|
|
|
Args:
|
|
adw_id: The ADW ID for the worktree to remove
|
|
logger: Logger instance
|
|
|
|
Returns:
|
|
Tuple of (success, error_message)
|
|
"""
|
|
worktree_path = get_worktree_path(adw_id)
|
|
|
|
# First remove via git
|
|
cmd = ["git", "worktree", "remove", worktree_path, "--force"]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
if result.returncode != 0:
|
|
# Try to clean up manually if git command failed
|
|
if os.path.exists(worktree_path):
|
|
try:
|
|
shutil.rmtree(worktree_path)
|
|
logger.warning(f"Manually removed worktree directory: {worktree_path}")
|
|
except Exception as e:
|
|
return False, f"Failed to remove worktree: {result.stderr}, manual cleanup failed: {e}"
|
|
|
|
logger.info(f"Removed worktree at {worktree_path}")
|
|
return True, None
|
|
|
|
|
|
def setup_worktree_environment(worktree_path: str, backend_port: int, frontend_port: int, logger: logging.Logger) -> None:
|
|
"""Set up worktree environment by creating .ports.env file.
|
|
|
|
The actual environment setup (copying .env files, installing dependencies) is handled
|
|
by the install_worktree.md command which runs inside the worktree.
|
|
|
|
Args:
|
|
worktree_path: Path to the worktree
|
|
backend_port: Backend port number
|
|
frontend_port: Frontend port number
|
|
logger: Logger instance
|
|
"""
|
|
# Create .ports.env file with port configuration
|
|
ports_env_path = os.path.join(worktree_path, ".ports.env")
|
|
|
|
with open(ports_env_path, "w") as f:
|
|
f.write(f"BACKEND_PORT={backend_port}\n")
|
|
f.write(f"FRONTEND_PORT={frontend_port}\n")
|
|
f.write(f"VITE_BACKEND_URL=http://localhost:{backend_port}\n")
|
|
|
|
logger.info(f"Created .ports.env with Backend: {backend_port}, Frontend: {frontend_port}")
|
|
|
|
|
|
# Port management functions
|
|
|
|
def get_ports_for_adw(adw_id: str) -> Tuple[int, int]:
|
|
"""Deterministically assign ports based on ADW ID.
|
|
|
|
Args:
|
|
adw_id: The ADW ID
|
|
|
|
Returns:
|
|
Tuple of (backend_port, frontend_port)
|
|
"""
|
|
# Convert first 8 chars of ADW ID to index (0-14)
|
|
# Using base 36 conversion and modulo to get consistent mapping
|
|
try:
|
|
# Take first 8 alphanumeric chars and convert from base 36
|
|
id_chars = ''.join(c for c in adw_id[:8] if c.isalnum())
|
|
index = int(id_chars, 36) % 15
|
|
except ValueError:
|
|
# Fallback to simple hash if conversion fails
|
|
index = hash(adw_id) % 15
|
|
|
|
backend_port = 9100 + index
|
|
frontend_port = 9200 + index
|
|
|
|
return backend_port, frontend_port
|
|
|
|
|
|
def is_port_available(port: int) -> bool:
|
|
"""Check if a port is available for binding.
|
|
|
|
Args:
|
|
port: Port number to check
|
|
|
|
Returns:
|
|
True if port is available, False otherwise
|
|
"""
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.settimeout(1)
|
|
s.bind(('localhost', port))
|
|
return True
|
|
except (socket.error, OSError):
|
|
return False
|
|
|
|
|
|
def find_next_available_ports(adw_id: str, max_attempts: int = 15) -> Tuple[int, int]:
|
|
"""Find available ports starting from deterministic assignment.
|
|
|
|
Args:
|
|
adw_id: The ADW ID
|
|
max_attempts: Maximum number of attempts (default 15)
|
|
|
|
Returns:
|
|
Tuple of (backend_port, frontend_port)
|
|
|
|
Raises:
|
|
RuntimeError: If no available ports found
|
|
"""
|
|
base_backend, base_frontend = get_ports_for_adw(adw_id)
|
|
base_index = base_backend - 9100
|
|
|
|
for offset in range(max_attempts):
|
|
index = (base_index + offset) % 15
|
|
backend_port = 9100 + index
|
|
frontend_port = 9200 + index
|
|
|
|
if is_port_available(backend_port) and is_port_available(frontend_port):
|
|
return backend_port, frontend_port
|
|
|
|
raise RuntimeError("No available ports in the allocated range") |