d024eec105
* fix(merge): resolve multiple merge-related issues (ACS-194, ACS-179, ACS-174, ACS-163)
- ACS-179: Fix TypeError in print_conflict_info - handle both string and dict conflict formats
- ACS-174: Handle git hook failures during merge - skip checkout if already on target branch
- ACS-163: Fix merge failure fallthrough - return False immediately when merge fails
- ACS-194: Improve AI merge success rate - add Haiku→Sonnet fallback with enhanced prompts
All fixes include regression tests.
* fix: improve merge robustness and fix test issues
This commit addresses multiple issues related to merge functionality
and test quality:
1. workspace.py:
- Fix case-insensitive natural language pattern matching to detect
AI explanations returned instead of code
2. worktree.py:
- Guard against None stderr when handling git hook failures
- Improve error messages for merge hook handling
3. workspace/display.py:
- Improve print_conflict_info() to properly categorize conflicts
(marker vs AI merge failures)
- Add severity indicators (🔴 for high, 🟡 for medium)
- Use shlex.quote() for proper git add command quoting
- Provide clearer guidance for different conflict types
4. tests/test_merge_ai_resolver.py:
- Fix test assertions to match actual AI_MERGE_SYSTEM_PROMPT content
(check for "intelligently" and "task's intent" instead of
non-existent "semantic understanding")
5. tests/test_workspace.py:
- Add side-effect verification for merge failure tests
- Remove unused fixtures
- Add assertions for severity emoji indicators
6. tests/test_worktree.py:
- Add subprocess return code checks for better test reliability
Related: ACS-194, ACS-179, ACS-174, ACS-163
* fix: improve display formatting and use proper imports
1. display.py:
- Fix trailing space when severity_icon is empty (low/unknown severity)
- Only add leading space to icon when icon is non-empty
2. workspace/__init__.py:
- Export AI_MERGE_SYSTEM_PROMPT and _build_merge_prompt
- Add to __all__ for public API access
3. test_merge_ai_resolver.py:
- Use standard imports from core.workspace package
- Remove fragile importlib.util dynamic loading
* fix: address all 6 review findings
1. workspace.py:
- Fix parameter naming: max_thinking -> max_thinking_tokens
(matches codebase convention used in 25+ locations)
- Extract hardcoded model constants: MERGE_FAST_MODEL,
MERGE_CAPABLE_MODEL, MERGE_FAST_THINKING, MERGE_COMPLEX_THINKING
- Improve natural language detection: check patterns at START of line
and require absence of code patterns to reduce false positives
2. display.py:
- Add critical severity icon handling (⛔ for critical severity)
3. worktree.py:
- Fix inconsistent stderr handling: use truthiness check for both
branches (empty strings show '<no stderr>')
4. test_workspace.py:
- Remove unused imports: patch, MagicMock from unittest.mock
Related: ACS-194
---------
Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
1354 lines
48 KiB
Python
1354 lines
48 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Git Worktree Manager - Per-Spec Architecture
|
|
=============================================
|
|
|
|
Each spec gets its own worktree:
|
|
- Worktree path: .auto-claude/worktrees/tasks/{spec-name}/
|
|
- Branch name: auto-claude/{spec-name}
|
|
|
|
This allows:
|
|
1. Multiple specs to be worked on simultaneously
|
|
2. Each spec's changes are isolated
|
|
3. Branches persist until explicitly merged
|
|
4. Clear 1:1:1 mapping: spec → worktree → branch
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import TypedDict, TypeVar
|
|
|
|
from core.git_executable import get_git_executable, run_git
|
|
from debug import debug_warning
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
def _is_retryable_network_error(stderr: str) -> bool:
|
|
"""Check if an error is a retryable network/connection issue."""
|
|
stderr_lower = stderr.lower()
|
|
return any(
|
|
term in stderr_lower
|
|
for term in ["connection", "network", "timeout", "reset", "refused"]
|
|
)
|
|
|
|
|
|
def _is_retryable_http_error(stderr: str) -> bool:
|
|
"""
|
|
Check if an HTTP error is retryable (5xx errors, timeouts).
|
|
Excludes auth errors (401, 403) and client errors (404, 422).
|
|
"""
|
|
stderr_lower = stderr.lower()
|
|
# Check for HTTP 5xx errors (server errors are retryable)
|
|
if re.search(r"http[s]?\s*5\d{2}", stderr_lower):
|
|
return True
|
|
# Check for HTTP timeout patterns
|
|
if "http" in stderr_lower and "timeout" in stderr_lower:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _with_retry(
|
|
operation: Callable[[], tuple[bool, T | None, str]],
|
|
max_retries: int = 3,
|
|
is_retryable: Callable[[str], bool] | None = None,
|
|
on_retry: Callable[[int, str], None] | None = None,
|
|
) -> tuple[T | None, str]:
|
|
"""
|
|
Execute an operation with retry logic.
|
|
|
|
Args:
|
|
operation: Function that returns a tuple of (success: bool, result: T | None, error: str).
|
|
On success (success=True), result contains the value and error is empty.
|
|
On failure (success=False), result is None and error contains the message.
|
|
max_retries: Maximum number of retry attempts
|
|
is_retryable: Function to check if error is retryable based on error message
|
|
on_retry: Optional callback called before each retry with (attempt, error)
|
|
|
|
Returns:
|
|
Tuple of (result, last_error) where result is T on success, None on failure
|
|
"""
|
|
last_error = ""
|
|
|
|
for attempt in range(1, max_retries + 1):
|
|
try:
|
|
success, result, error = operation()
|
|
if success:
|
|
return result, ""
|
|
|
|
last_error = error
|
|
|
|
# Check if error is retryable
|
|
if is_retryable and attempt < max_retries and is_retryable(error):
|
|
if on_retry:
|
|
on_retry(attempt, error)
|
|
backoff = 2 ** (attempt - 1)
|
|
time.sleep(backoff)
|
|
continue
|
|
|
|
break
|
|
|
|
except subprocess.TimeoutExpired:
|
|
last_error = "Operation timed out"
|
|
if attempt < max_retries:
|
|
if on_retry:
|
|
on_retry(attempt, last_error)
|
|
backoff = 2 ** (attempt - 1)
|
|
time.sleep(backoff)
|
|
continue
|
|
break
|
|
|
|
return None, last_error
|
|
|
|
|
|
class PushBranchResult(TypedDict, total=False):
|
|
"""Result of pushing a branch to remote."""
|
|
|
|
success: bool
|
|
branch: str
|
|
remote: str
|
|
error: str
|
|
|
|
|
|
class PullRequestResult(TypedDict, total=False):
|
|
"""Result of creating a pull request."""
|
|
|
|
success: bool
|
|
pr_url: str | None # None when PR was created but URL couldn't be extracted
|
|
already_exists: bool
|
|
error: str
|
|
message: str
|
|
|
|
|
|
class PushAndCreatePRResult(TypedDict, total=False):
|
|
"""Result of push_and_create_pr operation."""
|
|
|
|
success: bool
|
|
pushed: bool
|
|
remote: str
|
|
branch: str
|
|
pr_url: str | None # None when PR was created but URL couldn't be extracted
|
|
already_exists: bool
|
|
error: str
|
|
message: str
|
|
|
|
|
|
class WorktreeError(Exception):
|
|
"""Error during worktree operations."""
|
|
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class WorktreeInfo:
|
|
"""Information about a spec's worktree."""
|
|
|
|
path: Path
|
|
branch: str
|
|
spec_name: str
|
|
base_branch: str
|
|
is_active: bool = True
|
|
commit_count: int = 0
|
|
files_changed: int = 0
|
|
additions: int = 0
|
|
deletions: int = 0
|
|
last_commit_date: datetime | None = None
|
|
days_since_last_commit: int | None = None
|
|
|
|
|
|
class WorktreeManager:
|
|
"""
|
|
Manages per-spec Git worktrees.
|
|
|
|
Each spec gets its own worktree in .auto-claude/worktrees/tasks/{spec-name}/ with
|
|
a corresponding branch auto-claude/{spec-name}.
|
|
"""
|
|
|
|
# Timeout constants for subprocess operations
|
|
GIT_PUSH_TIMEOUT = 120 # 2 minutes for git push (network operations)
|
|
GH_CLI_TIMEOUT = 60 # 1 minute for gh CLI commands
|
|
GH_QUERY_TIMEOUT = 30 # 30 seconds for gh CLI queries
|
|
|
|
def __init__(self, project_dir: Path, base_branch: str | None = None):
|
|
self.project_dir = project_dir
|
|
self.base_branch = base_branch or self._detect_base_branch()
|
|
self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
|
self._merge_lock = asyncio.Lock()
|
|
|
|
def _detect_base_branch(self) -> str:
|
|
"""
|
|
Detect the base branch for worktree creation.
|
|
|
|
Priority order:
|
|
1. DEFAULT_BRANCH environment variable
|
|
2. Auto-detect main/master (if they exist)
|
|
3. Fall back to current branch (with warning)
|
|
|
|
Returns:
|
|
The detected base branch name
|
|
"""
|
|
# 1. Check for DEFAULT_BRANCH env var
|
|
env_branch = os.getenv("DEFAULT_BRANCH")
|
|
if env_branch:
|
|
# Verify the branch exists
|
|
result = run_git(
|
|
["rev-parse", "--verify", env_branch],
|
|
cwd=self.project_dir,
|
|
)
|
|
if result.returncode == 0:
|
|
return env_branch
|
|
else:
|
|
print(
|
|
f"Warning: DEFAULT_BRANCH '{env_branch}' not found, auto-detecting..."
|
|
)
|
|
|
|
# 2. Auto-detect main/master
|
|
for branch in ["main", "master"]:
|
|
result = run_git(
|
|
["rev-parse", "--verify", branch],
|
|
cwd=self.project_dir,
|
|
)
|
|
if result.returncode == 0:
|
|
return branch
|
|
|
|
# 3. Fall back to current branch with warning
|
|
current = self._get_current_branch()
|
|
print("Warning: Could not find 'main' or 'master' branch.")
|
|
print(f"Warning: Using current branch '{current}' as base for worktree.")
|
|
print("Tip: Set DEFAULT_BRANCH=your-branch in .env to avoid this.")
|
|
return current
|
|
|
|
def _get_current_branch(self) -> str:
|
|
"""Get the current git branch."""
|
|
result = run_git(
|
|
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=self.project_dir,
|
|
)
|
|
if result.returncode != 0:
|
|
raise WorktreeError(f"Failed to get current branch: {result.stderr}")
|
|
return result.stdout.strip()
|
|
|
|
def _run_git(
|
|
self, args: list[str], cwd: Path | None = None, timeout: int = 60
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run a git command and return the result.
|
|
|
|
Args:
|
|
args: Git command arguments (without 'git' prefix)
|
|
cwd: Working directory for the command
|
|
timeout: Command timeout in seconds (default: 60)
|
|
|
|
Returns:
|
|
CompletedProcess with command results. On timeout, returns a
|
|
CompletedProcess with returncode=-1 and timeout error in stderr.
|
|
"""
|
|
return run_git(args, cwd=cwd or self.project_dir, timeout=timeout)
|
|
|
|
def _unstage_gitignored_files(self) -> None:
|
|
"""
|
|
Unstage any staged files that are gitignored in the current branch,
|
|
plus any files in the .auto-claude directory which should never be merged.
|
|
|
|
This is needed after a --no-commit merge because files that exist in the
|
|
source branch (like spec files in .auto-claude/specs/) get staged even if
|
|
they're gitignored in the target branch.
|
|
"""
|
|
# Get list of staged files
|
|
result = self._run_git(["diff", "--cached", "--name-only"])
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
return
|
|
|
|
staged_files = result.stdout.strip().split("\n")
|
|
|
|
# Files to unstage: gitignored files + .auto-claude directory files
|
|
files_to_unstage = set()
|
|
|
|
# 1. Check which staged files are gitignored
|
|
# git check-ignore returns the files that ARE ignored
|
|
result = run_git(
|
|
["check-ignore", "--stdin"],
|
|
cwd=self.project_dir,
|
|
input_data="\n".join(staged_files),
|
|
)
|
|
|
|
if result.stdout.strip():
|
|
for file in result.stdout.strip().split("\n"):
|
|
if file.strip():
|
|
files_to_unstage.add(file.strip())
|
|
|
|
# 2. Always unstage .auto-claude directory files - these are project-specific
|
|
# and should never be merged from the worktree branch
|
|
auto_claude_patterns = [".auto-claude/", "auto-claude/specs/"]
|
|
for file in staged_files:
|
|
file = file.strip()
|
|
if not file:
|
|
continue
|
|
# Normalize path separators for cross-platform (Windows backslash support)
|
|
normalized = file.replace("\\", "/")
|
|
for pattern in auto_claude_patterns:
|
|
if normalized.startswith(pattern) or f"/{pattern}" in normalized:
|
|
files_to_unstage.add(file)
|
|
break
|
|
|
|
if files_to_unstage:
|
|
print(
|
|
f"Unstaging {len(files_to_unstage)} auto-claude/gitignored file(s)..."
|
|
)
|
|
# Unstage each file
|
|
for file in files_to_unstage:
|
|
self._run_git(["reset", "HEAD", "--", file])
|
|
|
|
def setup(self) -> None:
|
|
"""Create worktrees directory if needed."""
|
|
self.worktrees_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ==================== Per-Spec Worktree Methods ====================
|
|
|
|
def get_worktree_path(self, spec_name: str) -> Path:
|
|
"""Get the worktree path for a spec (checks new and legacy locations)."""
|
|
# New path first (.auto-claude/worktrees/tasks/)
|
|
new_path = self.worktrees_dir / spec_name
|
|
if new_path.exists():
|
|
return new_path
|
|
|
|
# Legacy fallback (.worktrees/ instead of .auto-claude/worktrees/tasks/)
|
|
legacy_path = self.project_dir / ".worktrees" / spec_name
|
|
if legacy_path.exists():
|
|
return legacy_path
|
|
|
|
# Return new path as default for creation
|
|
return new_path
|
|
|
|
def get_branch_name(self, spec_name: str) -> str:
|
|
"""Get the branch name for a spec."""
|
|
return f"auto-claude/{spec_name}"
|
|
|
|
def worktree_exists(self, spec_name: str) -> bool:
|
|
"""Check if a worktree exists for a spec."""
|
|
return self.get_worktree_path(spec_name).exists()
|
|
|
|
def get_worktree_info(self, spec_name: str) -> WorktreeInfo | None:
|
|
"""Get info about a spec's worktree."""
|
|
worktree_path = self.get_worktree_path(spec_name)
|
|
if not worktree_path.exists():
|
|
return None
|
|
|
|
# Verify the branch exists in the worktree
|
|
result = self._run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=worktree_path)
|
|
if result.returncode != 0:
|
|
return None
|
|
|
|
actual_branch = result.stdout.strip()
|
|
|
|
# Get statistics
|
|
stats = self._get_worktree_stats(spec_name)
|
|
|
|
return WorktreeInfo(
|
|
path=worktree_path,
|
|
branch=actual_branch,
|
|
spec_name=spec_name,
|
|
base_branch=self.base_branch,
|
|
is_active=True,
|
|
**stats,
|
|
)
|
|
|
|
def _check_branch_namespace_conflict(self) -> str | None:
|
|
"""
|
|
Check if a branch named 'auto-claude' exists, which would block creating
|
|
branches in the 'auto-claude/*' namespace.
|
|
|
|
Git stores branch refs as files under .git/refs/heads/, so a branch named
|
|
'auto-claude' creates a file that prevents creating the 'auto-claude/'
|
|
directory needed for 'auto-claude/{spec-name}' branches.
|
|
|
|
Returns:
|
|
The conflicting branch name if found, None otherwise.
|
|
"""
|
|
result = self._run_git(["rev-parse", "--verify", "auto-claude"])
|
|
if result.returncode == 0:
|
|
return "auto-claude"
|
|
return None
|
|
|
|
def _get_worktree_stats(self, spec_name: str) -> dict:
|
|
"""Get diff statistics for a worktree."""
|
|
worktree_path = self.get_worktree_path(spec_name)
|
|
|
|
stats = {
|
|
"commit_count": 0,
|
|
"files_changed": 0,
|
|
"additions": 0,
|
|
"deletions": 0,
|
|
"last_commit_date": None,
|
|
"days_since_last_commit": None,
|
|
}
|
|
|
|
if not worktree_path.exists():
|
|
return stats
|
|
|
|
# Commit count
|
|
result = self._run_git(
|
|
["rev-list", "--count", f"{self.base_branch}..HEAD"], cwd=worktree_path
|
|
)
|
|
if result.returncode == 0:
|
|
stats["commit_count"] = int(result.stdout.strip() or "0")
|
|
|
|
# Last commit date (most recent commit in this worktree)
|
|
result = self._run_git(
|
|
["log", "-1", "--format=%cd", "--date=iso"], cwd=worktree_path
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
try:
|
|
# Parse ISO date format: "2026-01-04 00:25:25 +0100"
|
|
date_str = result.stdout.strip()
|
|
# Convert git format to ISO format for fromisoformat()
|
|
# "2026-01-04 00:25:25 +0100" -> "2026-01-04T00:25:25+01:00"
|
|
parts = date_str.rsplit(" ", 1)
|
|
if len(parts) == 2:
|
|
date_part, tz_part = parts
|
|
# Convert timezone format: "+0100" -> "+01:00"
|
|
if len(tz_part) == 5 and (
|
|
tz_part.startswith("+") or tz_part.startswith("-")
|
|
):
|
|
tz_formatted = f"{tz_part[:3]}:{tz_part[3:]}"
|
|
iso_str = f"{date_part.replace(' ', 'T')}{tz_formatted}"
|
|
last_commit_date = datetime.fromisoformat(iso_str)
|
|
stats["last_commit_date"] = last_commit_date
|
|
# Use timezone-aware now() for accurate comparison
|
|
now_aware = datetime.now(last_commit_date.tzinfo)
|
|
stats["days_since_last_commit"] = (
|
|
now_aware - last_commit_date
|
|
).days
|
|
else:
|
|
# Fallback for unexpected timezone format
|
|
last_commit_date = datetime.strptime(
|
|
parts[0], "%Y-%m-%d %H:%M:%S"
|
|
)
|
|
stats["last_commit_date"] = last_commit_date
|
|
stats["days_since_last_commit"] = (
|
|
datetime.now() - last_commit_date
|
|
).days
|
|
else:
|
|
# No timezone in output
|
|
last_commit_date = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
|
|
stats["last_commit_date"] = last_commit_date
|
|
stats["days_since_last_commit"] = (
|
|
datetime.now() - last_commit_date
|
|
).days
|
|
except (ValueError, TypeError) as e:
|
|
# If parsing fails, silently continue without date info
|
|
pass
|
|
|
|
# Diff stats
|
|
result = self._run_git(
|
|
["diff", "--shortstat", f"{self.base_branch}...HEAD"], cwd=worktree_path
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
# Parse: "3 files changed, 50 insertions(+), 10 deletions(-)"
|
|
match = re.search(r"(\d+) files? changed", result.stdout)
|
|
if match:
|
|
stats["files_changed"] = int(match.group(1))
|
|
match = re.search(r"(\d+) insertions?", result.stdout)
|
|
if match:
|
|
stats["additions"] = int(match.group(1))
|
|
match = re.search(r"(\d+) deletions?", result.stdout)
|
|
if match:
|
|
stats["deletions"] = int(match.group(1))
|
|
|
|
return stats
|
|
|
|
def create_worktree(self, spec_name: str) -> WorktreeInfo:
|
|
"""
|
|
Create a worktree for a spec.
|
|
|
|
Args:
|
|
spec_name: The spec folder name (e.g., "002-implement-memory")
|
|
|
|
Returns:
|
|
WorktreeInfo for the created worktree
|
|
|
|
Raises:
|
|
WorktreeError: If a branch namespace conflict exists or worktree creation fails
|
|
"""
|
|
worktree_path = self.get_worktree_path(spec_name)
|
|
branch_name = self.get_branch_name(spec_name)
|
|
|
|
# Check for branch namespace conflict (e.g., 'auto-claude' blocking 'auto-claude/*')
|
|
conflicting_branch = self._check_branch_namespace_conflict()
|
|
if conflicting_branch:
|
|
raise WorktreeError(
|
|
f"Branch '{conflicting_branch}' exists and blocks creating '{branch_name}'.\n"
|
|
f"\n"
|
|
f"Git branch names work like file paths - a branch named 'auto-claude' prevents\n"
|
|
f"creating branches under 'auto-claude/' (like 'auto-claude/{spec_name}').\n"
|
|
f"\n"
|
|
f"Fix: Rename the conflicting branch:\n"
|
|
f" git branch -m {conflicting_branch} {conflicting_branch}-backup"
|
|
)
|
|
|
|
# Remove existing if present (from crashed previous run)
|
|
if worktree_path.exists():
|
|
self._run_git(["worktree", "remove", "--force", str(worktree_path)])
|
|
|
|
# Delete branch if it exists (from previous attempt)
|
|
self._run_git(["branch", "-D", branch_name])
|
|
|
|
# Fetch latest from remote to ensure we have the most up-to-date code
|
|
# GitHub/remote is the source of truth, not the local branch
|
|
fetch_result = self._run_git(["fetch", "origin", self.base_branch])
|
|
if fetch_result.returncode != 0:
|
|
print(
|
|
f"Warning: Could not fetch {self.base_branch} from origin: {fetch_result.stderr}"
|
|
)
|
|
print("Falling back to local branch...")
|
|
|
|
# Determine the start point for the worktree
|
|
# Prefer origin/{base_branch} (remote) over local branch to ensure we have latest code
|
|
remote_ref = f"origin/{self.base_branch}"
|
|
start_point = self.base_branch # Default to local branch
|
|
|
|
# Check if remote ref exists and use it as the source of truth
|
|
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
|
|
if check_remote.returncode == 0:
|
|
start_point = remote_ref
|
|
print(f"Creating worktree from remote: {remote_ref}")
|
|
else:
|
|
print(
|
|
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
|
|
)
|
|
|
|
# Create worktree with new branch from the start point (remote preferred)
|
|
result = self._run_git(
|
|
["worktree", "add", "-b", branch_name, str(worktree_path), start_point]
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
raise WorktreeError(
|
|
f"Failed to create worktree for {spec_name}: {result.stderr}"
|
|
)
|
|
|
|
print(f"Created worktree: {worktree_path.name} on branch {branch_name}")
|
|
|
|
return WorktreeInfo(
|
|
path=worktree_path,
|
|
branch=branch_name,
|
|
spec_name=spec_name,
|
|
base_branch=self.base_branch,
|
|
is_active=True,
|
|
)
|
|
|
|
def get_or_create_worktree(self, spec_name: str) -> WorktreeInfo:
|
|
"""
|
|
Get existing worktree or create a new one for a spec.
|
|
|
|
Args:
|
|
spec_name: The spec folder name
|
|
|
|
Returns:
|
|
WorktreeInfo for the worktree
|
|
"""
|
|
existing = self.get_worktree_info(spec_name)
|
|
if existing:
|
|
print(f"Using existing worktree: {existing.path}")
|
|
return existing
|
|
|
|
return self.create_worktree(spec_name)
|
|
|
|
def remove_worktree(self, spec_name: str, delete_branch: bool = False) -> None:
|
|
"""
|
|
Remove a spec's worktree.
|
|
|
|
Args:
|
|
spec_name: The spec folder name
|
|
delete_branch: Whether to also delete the branch
|
|
"""
|
|
worktree_path = self.get_worktree_path(spec_name)
|
|
branch_name = self.get_branch_name(spec_name)
|
|
|
|
if worktree_path.exists():
|
|
result = self._run_git(
|
|
["worktree", "remove", "--force", str(worktree_path)]
|
|
)
|
|
if result.returncode == 0:
|
|
print(f"Removed worktree: {worktree_path.name}")
|
|
else:
|
|
print(f"Warning: Could not remove worktree: {result.stderr}")
|
|
shutil.rmtree(worktree_path, ignore_errors=True)
|
|
|
|
if delete_branch:
|
|
self._run_git(["branch", "-D", branch_name])
|
|
print(f"Deleted branch: {branch_name}")
|
|
|
|
self._run_git(["worktree", "prune"])
|
|
|
|
def merge_worktree(
|
|
self, spec_name: str, delete_after: bool = False, no_commit: bool = False
|
|
) -> bool:
|
|
"""
|
|
Merge a spec's worktree branch back to base branch.
|
|
|
|
Args:
|
|
spec_name: The spec folder name
|
|
delete_after: Whether to remove worktree and branch after merge
|
|
no_commit: If True, merge changes but don't commit (stage only for review)
|
|
|
|
Returns:
|
|
True if merge succeeded
|
|
"""
|
|
info = self.get_worktree_info(spec_name)
|
|
if not info:
|
|
print(f"No worktree found for spec: {spec_name}")
|
|
return False
|
|
|
|
if no_commit:
|
|
print(
|
|
f"Merging {info.branch} into {self.base_branch} (staged, not committed)..."
|
|
)
|
|
else:
|
|
print(f"Merging {info.branch} into {self.base_branch}...")
|
|
|
|
# Switch to base branch in main project, but skip if already on it
|
|
# This avoids triggering git hooks unnecessarily
|
|
current_branch = self._get_current_branch()
|
|
if current_branch != self.base_branch:
|
|
result = self._run_git(["checkout", self.base_branch])
|
|
if result.returncode != 0:
|
|
# Check if this is a hook failure vs actual checkout failure
|
|
# Hook failures still change the branch but return non-zero
|
|
new_branch = self._get_current_branch()
|
|
if new_branch == self.base_branch:
|
|
# Branch did change - likely a hook failure, continue with merge
|
|
stderr_msg = result.stderr[:100] if result.stderr else "<no stderr>"
|
|
debug_warning(
|
|
"worktree",
|
|
f"Checkout succeeded but hook returned non-zero: {stderr_msg}",
|
|
)
|
|
else:
|
|
# Actual checkout failure
|
|
stderr_msg = result.stderr[:100] if result.stderr else "<no stderr>"
|
|
print(f"Error: Could not checkout base branch: {stderr_msg}")
|
|
return False
|
|
|
|
# Merge the spec branch
|
|
merge_args = ["merge", "--no-ff", info.branch]
|
|
if no_commit:
|
|
# --no-commit stages the merge but doesn't create the commit
|
|
merge_args.append("--no-commit")
|
|
else:
|
|
merge_args.extend(["-m", f"auto-claude: Merge {info.branch}"])
|
|
|
|
result = self._run_git(merge_args)
|
|
|
|
if result.returncode != 0:
|
|
print("Merge conflict! Aborting merge...")
|
|
self._run_git(["merge", "--abort"])
|
|
return False
|
|
|
|
if no_commit:
|
|
# Unstage any files that are gitignored in the main branch
|
|
# These get staged during merge because they exist in the worktree branch
|
|
self._unstage_gitignored_files()
|
|
print(
|
|
f"Changes from {info.branch} are now staged in your working directory."
|
|
)
|
|
print("Review the changes, then commit when ready:")
|
|
print(" git commit -m 'your commit message'")
|
|
else:
|
|
print(f"Successfully merged {info.branch}")
|
|
|
|
if delete_after:
|
|
self.remove_worktree(spec_name, delete_branch=True)
|
|
|
|
return True
|
|
|
|
def commit_in_worktree(self, spec_name: str, message: str) -> bool:
|
|
"""Commit all changes in a spec's worktree."""
|
|
worktree_path = self.get_worktree_path(spec_name)
|
|
if not worktree_path.exists():
|
|
return False
|
|
|
|
self._run_git(["add", "."], cwd=worktree_path)
|
|
result = self._run_git(["commit", "-m", message], cwd=worktree_path)
|
|
|
|
if result.returncode == 0:
|
|
return True
|
|
elif "nothing to commit" in result.stdout + result.stderr:
|
|
return True
|
|
else:
|
|
print(f"Commit failed: {result.stderr}")
|
|
return False
|
|
|
|
# ==================== Listing & Discovery ====================
|
|
|
|
def list_all_worktrees(self) -> list[WorktreeInfo]:
|
|
"""List all spec worktrees (includes legacy .worktrees/ location)."""
|
|
worktrees = []
|
|
seen_specs = set()
|
|
|
|
# Check new location first
|
|
if self.worktrees_dir.exists():
|
|
for item in self.worktrees_dir.iterdir():
|
|
if item.is_dir():
|
|
info = self.get_worktree_info(item.name)
|
|
if info:
|
|
worktrees.append(info)
|
|
seen_specs.add(item.name)
|
|
|
|
# Check legacy location (.worktrees/)
|
|
legacy_dir = self.project_dir / ".worktrees"
|
|
if legacy_dir.exists():
|
|
for item in legacy_dir.iterdir():
|
|
if item.is_dir() and item.name not in seen_specs:
|
|
info = self.get_worktree_info(item.name)
|
|
if info:
|
|
worktrees.append(info)
|
|
|
|
return worktrees
|
|
|
|
def list_all_spec_branches(self) -> list[str]:
|
|
"""List all auto-claude branches (even if worktree removed)."""
|
|
result = self._run_git(["branch", "--list", "auto-claude/*"])
|
|
if result.returncode != 0:
|
|
return []
|
|
|
|
branches = []
|
|
for line in result.stdout.strip().split("\n"):
|
|
branch = line.strip().lstrip("* ")
|
|
if branch:
|
|
branches.append(branch)
|
|
|
|
return branches
|
|
|
|
def get_changed_files(self, spec_name: str) -> list[tuple[str, str]]:
|
|
"""Get list of changed files in a spec's worktree."""
|
|
worktree_path = self.get_worktree_path(spec_name)
|
|
if not worktree_path.exists():
|
|
return []
|
|
|
|
result = self._run_git(
|
|
["diff", "--name-status", f"{self.base_branch}...HEAD"], cwd=worktree_path
|
|
)
|
|
|
|
files = []
|
|
for line in result.stdout.strip().split("\n"):
|
|
if not line:
|
|
continue
|
|
parts = line.split("\t", 1)
|
|
if len(parts) == 2:
|
|
files.append((parts[0], parts[1]))
|
|
|
|
return files
|
|
|
|
def get_change_summary(self, spec_name: str) -> dict:
|
|
"""Get a summary of changes in a worktree."""
|
|
files = self.get_changed_files(spec_name)
|
|
|
|
new_files = sum(1 for status, _ in files if status == "A")
|
|
modified_files = sum(1 for status, _ in files if status == "M")
|
|
deleted_files = sum(1 for status, _ in files if status == "D")
|
|
|
|
return {
|
|
"new_files": new_files,
|
|
"modified_files": modified_files,
|
|
"deleted_files": deleted_files,
|
|
}
|
|
|
|
def cleanup_all(self) -> None:
|
|
"""Remove all worktrees and their branches."""
|
|
for worktree in self.list_all_worktrees():
|
|
self.remove_worktree(worktree.spec_name, delete_branch=True)
|
|
|
|
def cleanup_stale_worktrees(self) -> None:
|
|
"""Remove worktrees that aren't registered with git."""
|
|
if not self.worktrees_dir.exists():
|
|
return
|
|
|
|
# Get list of registered worktrees
|
|
result = self._run_git(["worktree", "list", "--porcelain"])
|
|
registered_paths = set()
|
|
for line in result.stdout.split("\n"):
|
|
if line.startswith("worktree "):
|
|
registered_paths.add(Path(line.split(" ", 1)[1]))
|
|
|
|
# Remove unregistered directories
|
|
for item in self.worktrees_dir.iterdir():
|
|
if item.is_dir() and item not in registered_paths:
|
|
print(f"Removing stale worktree directory: {item.name}")
|
|
shutil.rmtree(item, ignore_errors=True)
|
|
|
|
self._run_git(["worktree", "prune"])
|
|
|
|
def get_test_commands(self, spec_name: str) -> list[str]:
|
|
"""Detect likely test/run commands for the project."""
|
|
worktree_path = self.get_worktree_path(spec_name)
|
|
commands = []
|
|
|
|
if (worktree_path / "package.json").exists():
|
|
commands.append("npm install && npm run dev")
|
|
commands.append("npm test")
|
|
|
|
if (worktree_path / "requirements.txt").exists():
|
|
commands.append("pip install -r requirements.txt")
|
|
|
|
if (worktree_path / "Cargo.toml").exists():
|
|
commands.append("cargo run")
|
|
commands.append("cargo test")
|
|
|
|
if (worktree_path / "go.mod").exists():
|
|
commands.append("go run .")
|
|
commands.append("go test ./...")
|
|
|
|
if not commands:
|
|
commands.append("# Check the project's README for run instructions")
|
|
|
|
return commands
|
|
|
|
def has_uncommitted_changes(self, spec_name: str | None = None) -> bool:
|
|
"""Check if there are uncommitted changes."""
|
|
cwd = None
|
|
if spec_name:
|
|
worktree_path = self.get_worktree_path(spec_name)
|
|
if worktree_path.exists():
|
|
cwd = worktree_path
|
|
result = self._run_git(["status", "--porcelain"], cwd=cwd)
|
|
return bool(result.stdout.strip())
|
|
|
|
# ==================== PR Creation Methods ====================
|
|
|
|
def push_branch(self, spec_name: str, force: bool = False) -> PushBranchResult:
|
|
"""
|
|
Push a spec's branch to the remote origin with retry logic.
|
|
|
|
Args:
|
|
spec_name: The spec folder name
|
|
force: Whether to force push (use with caution)
|
|
|
|
Returns:
|
|
PushBranchResult with keys:
|
|
- success: bool
|
|
- branch: str (branch name)
|
|
- remote: str (if successful)
|
|
- error: str (if failed)
|
|
"""
|
|
info = self.get_worktree_info(spec_name)
|
|
if not info:
|
|
return PushBranchResult(
|
|
success=False,
|
|
error=f"No worktree found for spec: {spec_name}",
|
|
)
|
|
|
|
# Push the branch to origin
|
|
push_args = ["push", "-u", "origin", info.branch]
|
|
if force:
|
|
push_args.insert(1, "--force")
|
|
|
|
def do_push() -> tuple[bool, PushBranchResult | None, str]:
|
|
"""Execute push operation for retry wrapper."""
|
|
try:
|
|
git_executable = get_git_executable()
|
|
result = subprocess.run(
|
|
[git_executable] + push_args,
|
|
cwd=info.path,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=self.GIT_PUSH_TIMEOUT,
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return (
|
|
True,
|
|
PushBranchResult(
|
|
success=True,
|
|
branch=info.branch,
|
|
remote="origin",
|
|
),
|
|
"",
|
|
)
|
|
return (False, None, result.stderr)
|
|
except FileNotFoundError:
|
|
return (False, None, "git executable not found")
|
|
|
|
max_retries = 3
|
|
result, last_error = _with_retry(
|
|
operation=do_push,
|
|
max_retries=max_retries,
|
|
is_retryable=_is_retryable_network_error,
|
|
)
|
|
|
|
if result:
|
|
return result
|
|
|
|
# Handle timeout error message
|
|
if last_error == "Operation timed out":
|
|
return PushBranchResult(
|
|
success=False,
|
|
branch=info.branch,
|
|
error=f"Push timed out after {max_retries} attempts.",
|
|
)
|
|
|
|
return PushBranchResult(
|
|
success=False,
|
|
branch=info.branch,
|
|
error=f"Failed to push branch: {last_error}",
|
|
)
|
|
|
|
def create_pull_request(
|
|
self,
|
|
spec_name: str,
|
|
target_branch: str | None = None,
|
|
title: str | None = None,
|
|
draft: bool = False,
|
|
) -> PullRequestResult:
|
|
"""
|
|
Create a GitHub pull request for a spec's branch using gh CLI with retry logic.
|
|
|
|
Args:
|
|
spec_name: The spec folder name
|
|
target_branch: Target branch for PR (defaults to base_branch)
|
|
title: PR title (defaults to spec name)
|
|
draft: Whether to create as draft PR
|
|
|
|
Returns:
|
|
PullRequestResult with keys:
|
|
- success: bool
|
|
- pr_url: str (if created)
|
|
- already_exists: bool (if PR already exists)
|
|
- error: str (if failed)
|
|
"""
|
|
info = self.get_worktree_info(spec_name)
|
|
if not info:
|
|
return PullRequestResult(
|
|
success=False,
|
|
error=f"No worktree found for spec: {spec_name}",
|
|
)
|
|
|
|
target = target_branch or self.base_branch
|
|
pr_title = title or f"auto-claude: {spec_name}"
|
|
|
|
# Get PR body from spec.md if available
|
|
pr_body = self._extract_spec_summary(spec_name)
|
|
|
|
# Build gh pr create command
|
|
gh_args = [
|
|
"gh",
|
|
"pr",
|
|
"create",
|
|
"--base",
|
|
target,
|
|
"--head",
|
|
info.branch,
|
|
"--title",
|
|
pr_title,
|
|
"--body",
|
|
pr_body,
|
|
]
|
|
if draft:
|
|
gh_args.append("--draft")
|
|
|
|
def is_pr_retryable(stderr: str) -> bool:
|
|
"""Check if PR creation error is retryable (network or HTTP 5xx)."""
|
|
return _is_retryable_network_error(stderr) or _is_retryable_http_error(
|
|
stderr
|
|
)
|
|
|
|
def do_create_pr() -> tuple[bool, PullRequestResult | None, str]:
|
|
"""Execute PR creation for retry wrapper."""
|
|
try:
|
|
result = subprocess.run(
|
|
gh_args,
|
|
cwd=info.path,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=self.GH_CLI_TIMEOUT,
|
|
)
|
|
|
|
# Check for "already exists" case (success, no retry needed)
|
|
if result.returncode != 0 and "already exists" in result.stderr.lower():
|
|
existing_url = self._get_existing_pr_url(spec_name, target)
|
|
result_dict = PullRequestResult(
|
|
success=True,
|
|
pr_url=existing_url,
|
|
already_exists=True,
|
|
)
|
|
if existing_url is None:
|
|
result_dict["message"] = (
|
|
"PR already exists but URL could not be retrieved"
|
|
)
|
|
return (True, result_dict, "")
|
|
|
|
if result.returncode == 0:
|
|
# Extract PR URL from output
|
|
pr_url: str | None = result.stdout.strip()
|
|
if not pr_url.startswith("http"):
|
|
# Try to find URL in output
|
|
# Use general pattern to support GitHub Enterprise instances
|
|
# Matches any HTTPS URL with /pull/<number> path
|
|
match = re.search(r"https://[^\s]+/pull/\d+", result.stdout)
|
|
if match:
|
|
pr_url = match.group(0)
|
|
else:
|
|
# Invalid output - no valid URL found
|
|
pr_url = None
|
|
|
|
return (
|
|
True,
|
|
PullRequestResult(
|
|
success=True,
|
|
pr_url=pr_url,
|
|
already_exists=False,
|
|
),
|
|
"",
|
|
)
|
|
|
|
return (False, None, result.stderr)
|
|
|
|
except FileNotFoundError:
|
|
# gh CLI not installed - not retryable, raise to exit retry loop
|
|
raise
|
|
|
|
max_retries = 3
|
|
try:
|
|
result, last_error = _with_retry(
|
|
operation=do_create_pr,
|
|
max_retries=max_retries,
|
|
is_retryable=is_pr_retryable,
|
|
)
|
|
|
|
if result:
|
|
return result
|
|
|
|
# Handle timeout error message
|
|
if last_error == "Operation timed out":
|
|
return PullRequestResult(
|
|
success=False,
|
|
error=f"PR creation timed out after {max_retries} attempts.",
|
|
)
|
|
|
|
return PullRequestResult(
|
|
success=False,
|
|
error=f"Failed to create PR: {last_error}",
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
# gh CLI not installed
|
|
return PullRequestResult(
|
|
success=False,
|
|
error="gh CLI not found. Install from https://cli.github.com/",
|
|
)
|
|
|
|
def _extract_spec_summary(self, spec_name: str) -> str:
|
|
"""Extract a summary from spec.md for PR body."""
|
|
worktree_path = self.get_worktree_path(spec_name)
|
|
spec_path = worktree_path / ".auto-claude" / "specs" / spec_name / "spec.md"
|
|
|
|
if not spec_path.exists():
|
|
# Try project spec path
|
|
spec_path = (
|
|
self.project_dir / ".auto-claude" / "specs" / spec_name / "spec.md"
|
|
)
|
|
|
|
if not spec_path.exists():
|
|
return "Auto-generated PR from Auto-Claude build."
|
|
|
|
try:
|
|
content = spec_path.read_text(encoding="utf-8")
|
|
# Extract first few paragraphs (skip title, get overview)
|
|
lines = content.split("\n")
|
|
summary_lines = []
|
|
in_content = False
|
|
|
|
for line in lines:
|
|
# Skip title headers
|
|
if line.startswith("# "):
|
|
continue
|
|
# Start capturing after first content line
|
|
if line.strip() and not line.startswith("#"):
|
|
in_content = True
|
|
if in_content:
|
|
if line.startswith("## ") and summary_lines:
|
|
break # Stop at next section
|
|
summary_lines.append(line)
|
|
if len(summary_lines) >= 10: # Limit to ~10 lines
|
|
break
|
|
|
|
summary = "\n".join(summary_lines).strip()
|
|
if summary:
|
|
return summary
|
|
except (OSError, UnicodeDecodeError) as e:
|
|
# Silently fall back to default - file read errors shouldn't block PR creation
|
|
debug_warning(
|
|
"worktree", f"Could not extract spec summary for PR body: {e}"
|
|
)
|
|
|
|
return "Auto-generated PR from Auto-Claude build."
|
|
|
|
def _get_existing_pr_url(self, spec_name: str, target_branch: str) -> str | None:
|
|
"""Get the URL of an existing PR for this branch."""
|
|
info = self.get_worktree_info(spec_name)
|
|
if not info:
|
|
return None
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["gh", "pr", "view", info.branch, "--json", "url", "--jq", ".url"],
|
|
cwd=info.path,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=self.GH_QUERY_TIMEOUT,
|
|
)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
except (
|
|
subprocess.TimeoutExpired,
|
|
FileNotFoundError,
|
|
subprocess.SubprocessError,
|
|
) as e:
|
|
# Silently ignore errors when fetching existing PR URL - this is a best-effort
|
|
# lookup that may fail due to network issues, missing gh CLI, or auth problems.
|
|
# Returning None allows the caller to handle missing URLs gracefully.
|
|
debug_warning("worktree", f"Could not get existing PR URL: {e}")
|
|
|
|
return None
|
|
|
|
def push_and_create_pr(
|
|
self,
|
|
spec_name: str,
|
|
target_branch: str | None = None,
|
|
title: str | None = None,
|
|
draft: bool = False,
|
|
force_push: bool = False,
|
|
) -> PushAndCreatePRResult:
|
|
"""
|
|
Push branch and create a pull request in one operation.
|
|
|
|
Args:
|
|
spec_name: The spec folder name
|
|
target_branch: Target branch for PR (defaults to base_branch)
|
|
title: PR title (defaults to spec name)
|
|
draft: Whether to create as draft PR
|
|
force_push: Whether to force push the branch
|
|
|
|
Returns:
|
|
PushAndCreatePRResult with keys:
|
|
- success: bool
|
|
- pr_url: str (if created)
|
|
- pushed: bool (if push succeeded)
|
|
- already_exists: bool (if PR already exists)
|
|
- error: str (if failed)
|
|
"""
|
|
# Step 1: Push the branch
|
|
push_result = self.push_branch(spec_name, force=force_push)
|
|
if not push_result.get("success"):
|
|
return PushAndCreatePRResult(
|
|
success=False,
|
|
pushed=False,
|
|
error=push_result.get("error", "Push failed"),
|
|
)
|
|
|
|
# Step 2: Create the PR
|
|
pr_result = self.create_pull_request(
|
|
spec_name=spec_name,
|
|
target_branch=target_branch,
|
|
title=title,
|
|
draft=draft,
|
|
)
|
|
|
|
# Combine results
|
|
return PushAndCreatePRResult(
|
|
success=pr_result.get("success", False),
|
|
pushed=True,
|
|
remote=push_result.get("remote"),
|
|
branch=push_result.get("branch"),
|
|
pr_url=pr_result.get("pr_url"),
|
|
already_exists=pr_result.get("already_exists", False),
|
|
error=pr_result.get("error"),
|
|
)
|
|
|
|
# ==================== Worktree Cleanup Methods ====================
|
|
|
|
def get_old_worktrees(
|
|
self, days_threshold: int = 30, include_stats: bool = False
|
|
) -> list[WorktreeInfo] | list[str]:
|
|
"""
|
|
Find worktrees that haven't been modified in the specified number of days.
|
|
|
|
Args:
|
|
days_threshold: Number of days without activity to consider a worktree old (default: 30)
|
|
include_stats: If True, return full WorktreeInfo objects; if False, return just spec names
|
|
|
|
Returns:
|
|
List of old worktrees (either WorktreeInfo objects or spec names based on include_stats)
|
|
"""
|
|
old_worktrees = []
|
|
|
|
for worktree_info in self.list_all_worktrees():
|
|
# Skip if we can't determine age
|
|
if worktree_info.days_since_last_commit is None:
|
|
continue
|
|
|
|
if worktree_info.days_since_last_commit >= days_threshold:
|
|
if include_stats:
|
|
old_worktrees.append(worktree_info)
|
|
else:
|
|
old_worktrees.append(worktree_info.spec_name)
|
|
|
|
return old_worktrees
|
|
|
|
def cleanup_old_worktrees(
|
|
self, days_threshold: int = 30, dry_run: bool = False
|
|
) -> tuple[list[str], list[str]]:
|
|
"""
|
|
Remove worktrees that haven't been modified in the specified number of days.
|
|
|
|
Args:
|
|
days_threshold: Number of days without activity to consider a worktree old (default: 30)
|
|
dry_run: If True, only report what would be removed without actually removing
|
|
|
|
Returns:
|
|
Tuple of (removed_specs, failed_specs) containing spec names
|
|
"""
|
|
old_worktrees = self.get_old_worktrees(
|
|
days_threshold=days_threshold, include_stats=True
|
|
)
|
|
|
|
if not old_worktrees:
|
|
print(f"No worktrees found older than {days_threshold} days.")
|
|
return ([], [])
|
|
|
|
removed = []
|
|
failed = []
|
|
|
|
if dry_run:
|
|
print(f"\n[DRY RUN] Would remove {len(old_worktrees)} old worktrees:")
|
|
for info in old_worktrees:
|
|
print(
|
|
f" - {info.spec_name} (last activity: {info.days_since_last_commit} days ago)"
|
|
)
|
|
return ([], [])
|
|
|
|
print(f"\nRemoving {len(old_worktrees)} old worktrees...")
|
|
for info in old_worktrees:
|
|
try:
|
|
self.remove_worktree(info.spec_name, delete_branch=True)
|
|
removed.append(info.spec_name)
|
|
print(
|
|
f" ✓ Removed {info.spec_name} (last activity: {info.days_since_last_commit} days ago)"
|
|
)
|
|
except Exception as e:
|
|
failed.append(info.spec_name)
|
|
print(f" ✗ Failed to remove {info.spec_name}: {e}")
|
|
|
|
if removed:
|
|
print(f"\nSuccessfully removed {len(removed)} worktree(s).")
|
|
if failed:
|
|
print(f"Failed to remove {len(failed)} worktree(s).")
|
|
|
|
return (removed, failed)
|
|
|
|
def get_worktree_count_warning(
|
|
self, warning_threshold: int = 10, critical_threshold: int = 20
|
|
) -> str | None:
|
|
"""
|
|
Check worktree count and return a warning message if threshold is exceeded.
|
|
|
|
Args:
|
|
warning_threshold: Number of worktrees to trigger a warning (default: 10)
|
|
critical_threshold: Number of worktrees to trigger a critical warning (default: 20)
|
|
|
|
Returns:
|
|
Warning message string if threshold exceeded, None otherwise
|
|
"""
|
|
worktrees = self.list_all_worktrees()
|
|
count = len(worktrees)
|
|
|
|
if count >= critical_threshold:
|
|
old_worktrees = self.get_old_worktrees(days_threshold=30)
|
|
old_count = len(old_worktrees)
|
|
return (
|
|
f"CRITICAL: {count} worktrees detected! "
|
|
f"Consider cleaning up old worktrees ({old_count} are 30+ days old). "
|
|
f"Run cleanup to remove stale worktrees."
|
|
)
|
|
elif count >= warning_threshold:
|
|
old_worktrees = self.get_old_worktrees(days_threshold=30)
|
|
old_count = len(old_worktrees)
|
|
return (
|
|
f"WARNING: {count} worktrees detected. "
|
|
f"{old_count} are 30+ days old and may be safe to clean up."
|
|
)
|
|
|
|
return None
|
|
|
|
def print_worktree_summary(self) -> None:
|
|
"""Print a summary of all worktrees with age information."""
|
|
worktrees = self.list_all_worktrees()
|
|
|
|
if not worktrees:
|
|
print("No worktrees found.")
|
|
return
|
|
|
|
print(f"\n{'=' * 80}")
|
|
print(f"Worktree Summary ({len(worktrees)} total)")
|
|
print(f"{'=' * 80}\n")
|
|
|
|
# Group by age
|
|
recent = [] # < 7 days
|
|
week_old = [] # 7-30 days
|
|
month_old = [] # 30-90 days
|
|
very_old = [] # > 90 days
|
|
unknown_age = []
|
|
|
|
for info in worktrees:
|
|
if info.days_since_last_commit is None:
|
|
unknown_age.append(info)
|
|
elif info.days_since_last_commit < 7:
|
|
recent.append(info)
|
|
elif info.days_since_last_commit < 30:
|
|
week_old.append(info)
|
|
elif info.days_since_last_commit < 90:
|
|
month_old.append(info)
|
|
else:
|
|
very_old.append(info)
|
|
|
|
def print_group(title: str, items: list[WorktreeInfo]):
|
|
if not items:
|
|
return
|
|
print(f"{title} ({len(items)}):")
|
|
for info in sorted(items, key=lambda x: x.spec_name):
|
|
age_str = (
|
|
f"{info.days_since_last_commit}d ago"
|
|
if info.days_since_last_commit is not None
|
|
else "unknown"
|
|
)
|
|
print(f" - {info.spec_name} (last activity: {age_str})")
|
|
print()
|
|
|
|
print_group("Recent (< 7 days)", recent)
|
|
print_group("Week Old (7-30 days)", week_old)
|
|
print_group("Month Old (30-90 days)", month_old)
|
|
print_group("Very Old (> 90 days)", very_old)
|
|
print_group("Unknown Age", unknown_age)
|
|
|
|
# Print cleanup suggestions
|
|
if month_old or very_old:
|
|
total_old = len(month_old) + len(very_old)
|
|
print(f"{'=' * 80}")
|
|
print(
|
|
f"💡 Suggestion: {total_old} worktree(s) are 30+ days old and may be safe to clean up."
|
|
)
|
|
print(" Review these worktrees and run cleanup if no longer needed.")
|
|
print(f"{'=' * 80}\n")
|