Initial commit
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user