a74bd8656e
* feat: add PR creation workflow for task worktrees Adds the ability to push a worktree branch and create a GitHub Pull Request directly from the Auto-Claude UI, instead of manually merging changes locally. ## User Flow 1. User completes a task build in an isolated worktree 2. Instead of clicking "Merge", user can click "Create PR" button 3. A dialog shows source branch → target branch (default: develop) 4. User confirms, system pushes branch and creates GitHub PR via `gh` CLI 5. PR URL is displayed and can be opened in browser ## Changes ### Backend (Python) - Added `push_branch()` with timeout (120s) for git push - Added `create_pull_request()` with timeout (60s) for gh CLI - Added `push_and_create_pr()` orchestrator - Added `--create-pr` CLI argument with handler - Added BRANCH and LINK icons with unique ASCII fallbacks ### Frontend (TypeScript) - Added `WorktreeCreatePRResult` type - Added `TASK_WORKTREE_CREATE_PR` IPC channel - Added IPC handler with 2-min timeout and EAFP pattern - Added `createWorktreePR` preload API method - Created reusable `CreatePRDialog` component - Integrated PR button in `WorkspaceStatus` - Added i18n translations (EN + FR) ## Code Review Fixes (from PR #606) - All subprocess calls have timeouts (TimeoutExpired handled) - EAFP pattern for file existence checks (no TOCTOU) - IPC handler has timeout with process cleanup - Icon ASCII fallbacks are unique (`[BR]` for BRANCH, `[L]` for LINK) - All user-facing strings use i18n translation keys - Translations added to BOTH en/*.json AND fr/*.json - CreatePRDialog component is reusable - Proper typed objects (no type assertions) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review comments and add PR status persistence Review comment fixes: - Fix NameError: use args.base_branch instead of undefined base_branch (main.py) - Add JSON output for frontend IPC consumption (main.py) - Narrow exception handling in _extract_spec_summary to (OSError, UnicodeDecodeError) - Narrow exception handling in _get_existing_pr_url to subprocess-specific exceptions - Add debug logging for exception cases in worktree.py - Add 'exit' event handler to IPC handler for robustness (worktree-handlers.ts) Additional improvements: - Persist PR status to both main and worktree locations - Add CreatePR button to Worktrees page with i18n support - Add CreatePRDialog tests (11 test cases) - Fix i18n compliance for all new strings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(a11y): use button instead of anchor for PR link action Addresses review comment: anchor elements should only be used for navigation, not for triggering actions. Using a button improves accessibility for screen readers and keyboard users. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: address nitpick review comments Backend (worktree.py): - Add TypedDict types (PushBranchResult, PullRequestResult) for better type safety - Add retry logic with exponential backoff (3 attempts) for transient network failures - Retries on: connection errors, network issues, timeouts, reset connections Frontend: - Fix checkbox accessibility: add explicit id/htmlFor for draft PR checkbox - Normalize return type in TaskDetailModal.handleCreatePR to include all fields - Add message field to WorktreeCreatePRResult for consistency with other result types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: remove duplicate JSON output in create-pr command The JSON was being printed twice: 1. In workspace_commands.py handle_create_pr_command() 2. In main.py after calling handle_create_pr_command() This caused JSON.parse to fail with "Unexpected non-whitespace character after JSON" when the frontend tried to parse the output. Removed the duplicate print from main.py since workspace_commands.py already handles JSON output for frontend parsing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: make IPC handler debug logging conditional Debug output for MERGE and CREATE_PR handlers now only appears when: - process.env.DEBUG === 'true', OR - process.env.NODE_ENV === 'development' This matches the pattern used elsewhere in the codebase (project-initializer.ts, terminal-name-generator.ts, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address code review feedback on JSON parsing and status persistence - Use non-greedy regex pattern to extract last complete JSON object from stdout, avoiding issues with multiple JSON objects or garbage - Add validation that parsed JSON has expected shape before using (typeof checks for success, pr_url, already_exists, error fields) - Await persistPlanStatus calls instead of fire-and-forget to ensure status is persisted before resolving the IPC handler 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: ensure parent directory exists before writing metadata Add mkdirSync with recursive:true before writeFileSync in updateTaskMetadataPrUrl to prevent write failures when the parent directory doesn't exist. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: add TypedDict for push_and_create_pr return type Add PushAndCreatePRResult TypedDict with all fields (success, pushed, remote, branch, pr_url, already_exists, error) for static type safety. Update push_and_create_pr method signature and return statements to use the TypedDict constructor. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(i18n): use feminine form for PR in French translation Change "PR créé" to "PR créée" to match French grammatical gender (PR is feminine: "la PR"). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(i18n): use translation key for Open PR button Replace hardcoded "Open PR" label with i18n key common:buttons.openPR in Worktrees.tsx. Add translation keys to en/common.json and fr/common.json. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(a11y): use semantic button for PR link in TaskMetadata Replace anchor element with semantic button for better accessibility. Screen readers now properly announce this as an interactive control. The visible URL text provides an accessible label. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(a11y,i18n): use semantic button and i18n for PR status in TaskDetailModal - Replace anchor element with semantic button for PR link - Replace hardcoded "PR Created" with t('tasks:status.prCreated') - Apply fix to both the completion state link and the badge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(i18n): use translation keys for PR button in WorkspaceStatus Add useTranslation hook and replace hardcoded strings: - "Creating PR..." → t('taskReview:pr.actions.creating') - "Create PR" → t('common:buttons.createPR') 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: scope numeric assertions to stats container in CreatePRDialog Use within() to scope commit count and changes assertions to the stats container, avoiding accidental matches elsewhere in the dialog. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: handle success results without prUrl in CreatePRDialog Allow success state to render even without a URL (e.g., from the "no JSON in output, assuming success" fallback). The PR link button is now conditionally rendered only when prUrl is present. This prevents the dialog from showing an empty body when the backend returns { success: true, prUrl: undefined }. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review findings for PR creation feature Backend (worktree.py): - Validate PR URL extraction - set pr_url to None if no valid URL found - Add message field to TypedDicts for informative feedback - Handle missing URL gracefully for existing PRs with message Frontend (worktree-handlers.ts): - Add GIT_BRANCH_REGEX and PR_CREATION_TIMEOUT_MS as module-level constants - Add input validation for targetBranch parameter - Add branch name validation in getTaskBaseBranch - Fix inconsistent JSON regex pattern between success/error paths Tests (CreatePRDialog.test.tsx): - Add test for draft PR checkbox functionality - Add test for 'already exists' PR state - Add test for success without prUrl Constants (task.ts): - Add pr_created to TASK_STATUS_LABELS and TASK_STATUS_COLORS 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract helper functions from TASK_WORKTREE_CREATE_PR handler - Extract parsePRJsonOutput() for JSON parsing with snake_case/camelCase - Extract updateTaskStatusAfterPRCreation() for metadata updates - Extract buildCreatePRArgs() for argument construction with validation - Extract initializePythonEnvForPR() for Python environment setup - Add generic withRetry() helper with exponential backoff - Refactor inline updatePlanWithRetry() to use withRetry() helper Addresses HIGH priority review finding about handler complexity and MEDIUM priority finding about duplicated retry logic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address additional PR review findings Backend (worktree.py): - Update PullRequestResult.pr_url and PushAndCreatePRResult.pr_url to allow None (str | None) for cases where PR was created but URL couldn't be extracted Frontend (CreatePRDialog): - Add data-testid="pr-stats-container" for stable test targeting - Update test to use getByTestId instead of brittle CSS class selector Frontend (TaskDetailModal): - Remove hardcoded English error strings from handleCreatePR - Propagate IPC errors directly, let CreatePRDialog use i18n fallbacks Frontend (TaskMetadata): - Add i18n support for "Pull Request" header label - Add translation keys to en/tasks.json and fr/tasks.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: handle success default and retry validation in PR handlers - Default success to false in parsePRJsonOutput to avoid masking failures when the field is missing from the JSON response - Add validation to withRetry to ensure at least one attempt is made by clamping maxRetries to a minimum of 1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(i18n): remove hardcoded error strings from Worktrees handleCreatePR Let CreatePRDialog handle i18n fallback for undefined error values instead of hardcoding 'Failed to create PR' and 'Unknown error'. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: reset isCreating flag when CreatePRDialog opens Prevents stale loading state when reopening the dialog after a previous PR creation attempt. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): use os.tmpdir() for cross-platform temp path matching Tests were hardcoded to expect /tmp/ but macOS uses /var/folders/.../T/ for temp files. Now dynamically uses os.tmpdir() for platform-independent path matching. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: apply pre-commit auto-fixes - Remove trailing whitespace from 20 files - Fix ruff lint errors in Python files - Apply ruff formatting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeQL and code review findings - Extract escapeForRegex helper in claude-integration-handler.test.ts to deduplicate regex-escaping logic and avoid ReDoS false-positive - Anchor regex pattern in CreatePRDialog.test.tsx to prevent arbitrary host matching (CodeQL security alert) - Remove unused ExternalLink import from TaskCard.tsx - Add defensive window.electronAPI check in CreatePRDialog handleOpenPR to avoid runtime errors in test/misconfigured environments 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeQL and code review findings Frontend: - Fix CodeQL regex anchor issue in CreatePRDialog.test.tsx by using data-testid="pr-link-button" instead of URL regex pattern - Add data-testid to PR link button in CreatePRDialog.tsx - Add defensive window.electronAPI?.openExternal check in TaskCard.tsx Backend: - Add CreatePRResult TypedDict for type-safe return values - Wrap push_and_create_pr call in try/except for clean JSON output on exceptions instead of unhandled tracebacks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add frontend validation for PR creation form - Add client-side validation for branch names and PR titles - Validate git branch name format (alphanumeric, hyphens, underscores, slashes) - Ensure PR title is not empty - Provide immediate user feedback before backend submission - Add localized error messages in English and French * refactor: improve error handling and import organization in PR creation - Clean up CreatePRResult error structure: separate user-friendly 'message' from technical 'error' field - Move get_existing_build_worktree import to module-level imports for consistency - Remove redundant local import inside handle_create_pr_command function - Improve API clarity by providing both user messages and technical error details * refactor: properly convert PushAndCreatePRResult to CreatePRResult in CLI handler - Convert raw PushAndCreatePRResult to expected CreatePRResult shape - Map fields appropriately: success, pr_url, already_exists, error, message - Maintain type safety by returning declared CreatePRResult instead of raw result - Preserve all essential information while conforming to API contract - Improve code maintainability and type correctness * feat: include push and branch details in CreatePRResult - Add pushed, remote, and branch fields to CreatePRResult type - Include push status, remote name, and branch name in CLI result - Provide complete operation details for frontend consumption - Enhance API with comprehensive PR creation status information - Maintain backward compatibility while adding useful metadata * fix: improve type safety and i18n consistency for task status - Add isValidDropColumn type guard in KanbanBoard.tsx to preserve literal types from TASK_STATUS_COLUMNS instead of using unsafe cast - Replace duplicate CheckCircle2 with GitPullRequest icon in TaskDetailModal PR button for visual consistency with TaskCard - Normalize pr_created i18n key to columns.pr_created namespace - Add pr_created translation keys to en/fr tasks.json columns section - Update all hardcoded status.prCreated references to use mapping 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: remove duplicate PR Created badges and unused import - Remove unused ExternalLink import from TaskDetailModal.tsx - Fix duplicate badge rendering for pr_created status in both TaskCard and TaskDetailModal - Consolidate to single badge showing 'PR Created' for completed PR tasks * refactor: extract status badge variant logic and use i18n for completion text - Extract complex badge variant ternary into getStatusBadgeVariant helper function in TaskDetailModal - Replace hardcoded 'Task completed' with i18n translation t('tasks:status.complete') - Update getStatusBadgeVariant in TaskCard to return 'success' for pr_created status - Use getStatusBadgeVariant consistently instead of hardcoded variant in pr_created conditional * fix: use optional chaining for electronAPI in PR URL button - Update TaskDetailModal PR URL button onClick to use window.electronAPI?.openExternal - Matches the pattern used in TaskCard.tsx handleViewPR function - Prevents runtime errors when electronAPI is undefined * fix: add URL validation for parsed PR URLs Add isValidGitHubUrl() helper to validate PR URLs are valid https://github.com or *.github.com URLs before using them. This improves robustness by filtering out invalid URLs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract WorktreeCreatePROptions into named exported type Extract the inline options object from createWorktreePR signature into a reusable named type. Updated all callers and related declarations to use the new type for consistency across components. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use WorktreeCreatePROptions type and add defensive optional chaining - Update createWorktreePR implementation to use WorktreeCreatePROptions instead of inline type (matches interface declaration) - Add optional chaining for window.electronAPI?.openExternal in Worktrees - Remove unused ExternalLink import from Worktrees component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review findings for code quality - Extract retry helper functions in worktree.py for DRY network error handling - Fix broad 'http' retry condition to exclude auth errors (401, 403) - Add Windows taskkill fallback for forceful process termination - Import CreatePRResult from worktree.py instead of duplicating TypedDict - Move import to top of worktree.py following Python conventions - Return result object from updateTaskStatusAfterPRCreation for better state tracking - Add PR title validation (printable chars, 256 char max) - Use WorktreeCreatePROptions type consistently in handler 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review findings - dedupe retry logic and support GH Enterprise URLs - Refactor push_branch and create_pull_request to use _with_retry helper instead of duplicated retry loops (addresses code duplication issue) - Update isValidGitHubUrl to accept any HTTPS URL with /pull/\d+ path to support GitHub Enterprise instances with custom domains Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): relax isValidGitHubUrl validation for GH Enterprise support - Remove /pull/\d+ path requirement that was too strict - Only require HTTPS protocol and non-empty hostname - Allows GitHub Enterprise URLs with custom domains to be parsed correctly Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit feedback for PR creation - Fix undefined base_branch variable in CLI main.py with proper auto-detection - Improve event handling in worktree-handlers.ts with comprehensive exit event support - Fix dynamic retry count in error messages instead of hardcoded '3 attempts' - Use get_git_executable() and handle FileNotFoundError in push_branch method - Move debug_warning import to module level for better performance - Ensure all error messages reflect actual retry counts used * fix: address additional PR review feedback - main.py: Simplify PR creation by passing pr_target directly to handler, letting WorktreeManager._detect_base_branch handle detection internally - worktree.py: Fix _with_retry type signature to match actual tuple return, use get_git_executable() for proper git path resolution, move debug_warning import to top of file - worktree-handlers.ts: Extract duplicated close/exit callback logic into handleCreatePRProcessExit helper function - workspace_commands.py: Remove redundant json import (CodeQL fix) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): clear GIT_INDEX_FILE in temp_git_repo fixture Pre-commit sets GIT_INDEX_FILE to a relative path (.git/index.pre-commit) which causes git commands in temp repos to fail with "index file open failed: Not a directory" because the relative path resolves against the main repo instead of the temp repo. The fix saves and clears GIT_INDEX_FILE before creating the temp repo, then restores it in a finally block to ensure cleanup. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use proper base branch fallback for PR creation target The worktree status handlers were incorrectly determining baseBranch by checking the current HEAD branch in the main project directory. This caused the PR creation dialog to pre-populate the target branch with the user's current feature branch instead of main/develop. Added getEffectiveBaseBranch() helper that properly determines the base branch using this priority: 1. Task metadata baseBranch (from task_metadata.json) 2. Project settings mainBranch 3. Git detection (main/master branch existence) 4. Fallback to 'main' Fixed three handlers: - TASK_WORKTREE_STATUS - TASK_WORKTREE_DIFF - List worktrees helper Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
1221 lines
42 KiB
Python
1221 lines
42 KiB
Python
"""
|
|
Workspace Commands
|
|
==================
|
|
|
|
CLI commands for workspace management (merge, review, discard, list, cleanup)
|
|
"""
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Ensure parent directory is in path for imports (before other imports)
|
|
_PARENT_DIR = Path(__file__).parent.parent
|
|
if str(_PARENT_DIR) not in sys.path:
|
|
sys.path.insert(0, str(_PARENT_DIR))
|
|
|
|
from core.workspace.git_utils import (
|
|
_is_auto_claude_file,
|
|
apply_path_mapping,
|
|
detect_file_renames,
|
|
get_file_content_from_ref,
|
|
get_merge_base,
|
|
is_lock_file,
|
|
)
|
|
from core.worktree import PushAndCreatePRResult as CreatePRResult
|
|
from core.worktree import WorktreeManager
|
|
from debug import debug_warning
|
|
from ui import (
|
|
Icons,
|
|
icon,
|
|
)
|
|
from workspace import (
|
|
cleanup_all_worktrees,
|
|
discard_existing_build,
|
|
get_existing_build_worktree,
|
|
list_all_worktrees,
|
|
merge_existing_build,
|
|
review_existing_build,
|
|
)
|
|
|
|
from .utils import print_banner
|
|
|
|
|
|
def _detect_default_branch(project_dir: Path) -> str:
|
|
"""
|
|
Detect the default branch for the repository.
|
|
|
|
This matches the logic in WorktreeManager._detect_base_branch() to ensure
|
|
we compare against the same branch that worktrees are created from.
|
|
|
|
Priority order:
|
|
1. DEFAULT_BRANCH environment variable
|
|
2. Auto-detect main/master (if they exist)
|
|
3. Fall back to "main" as final default
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
|
|
Returns:
|
|
The detected default branch name
|
|
"""
|
|
import os
|
|
|
|
# 1. Check for DEFAULT_BRANCH env var
|
|
env_branch = os.getenv("DEFAULT_BRANCH")
|
|
if env_branch:
|
|
# Verify the branch exists
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "--verify", env_branch],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode == 0:
|
|
return env_branch
|
|
|
|
# 2. Auto-detect main/master
|
|
for branch in ["main", "master"]:
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "--verify", branch],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode == 0:
|
|
return branch
|
|
|
|
# 3. Fall back to "main" as final default
|
|
return "main"
|
|
|
|
|
|
def _get_changed_files_from_git(
|
|
worktree_path: Path, base_branch: str = "main"
|
|
) -> list[str]:
|
|
"""
|
|
Get list of files changed by the task (not files changed on base branch).
|
|
|
|
Uses merge-base to accurately identify only the files modified in the worktree,
|
|
not files that changed on the base branch since the worktree was created.
|
|
|
|
Args:
|
|
worktree_path: Path to the worktree
|
|
base_branch: Base branch to compare against (default: main)
|
|
|
|
Returns:
|
|
List of changed file paths (task changes only)
|
|
"""
|
|
try:
|
|
# First, get the merge-base (the point where the worktree branched)
|
|
merge_base_result = subprocess.run(
|
|
["git", "merge-base", base_branch, "HEAD"],
|
|
cwd=worktree_path,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
merge_base = merge_base_result.stdout.strip()
|
|
|
|
# Use two-dot diff from merge-base to get only task's changes
|
|
result = subprocess.run(
|
|
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
|
cwd=worktree_path,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
|
return files
|
|
except subprocess.CalledProcessError as e:
|
|
# Log the failure before trying fallback
|
|
debug_warning(
|
|
"workspace_commands",
|
|
f"git diff with merge-base failed: returncode={e.returncode}, "
|
|
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
|
)
|
|
# Fallback: try direct two-arg diff (less accurate but works)
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "diff", "--name-only", base_branch, "HEAD"],
|
|
cwd=worktree_path,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
|
return files
|
|
except subprocess.CalledProcessError as e:
|
|
# Log the failure before returning empty list
|
|
debug_warning(
|
|
"workspace_commands",
|
|
f"git diff (fallback) failed: returncode={e.returncode}, "
|
|
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
|
)
|
|
return []
|
|
|
|
|
|
def _detect_worktree_base_branch(
|
|
project_dir: Path,
|
|
worktree_path: Path,
|
|
spec_name: str,
|
|
) -> str | None:
|
|
"""
|
|
Detect which branch a worktree was created from.
|
|
|
|
Tries multiple strategies:
|
|
1. Check worktree config file (.auto-claude/worktree-config.json)
|
|
2. Find merge-base with known branches (develop, main, master)
|
|
3. Return None if unable to detect
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
worktree_path: Path to the worktree
|
|
spec_name: Name of the spec
|
|
|
|
Returns:
|
|
The detected base branch name, or None if unable to detect
|
|
"""
|
|
# Strategy 1: Check for worktree config file
|
|
config_path = worktree_path / ".auto-claude" / "worktree-config.json"
|
|
if config_path.exists():
|
|
try:
|
|
config = json.loads(config_path.read_text())
|
|
if config.get("base_branch"):
|
|
debug(
|
|
MODULE,
|
|
f"Found base branch in worktree config: {config['base_branch']}",
|
|
)
|
|
return config["base_branch"]
|
|
except Exception as e:
|
|
debug_warning(MODULE, f"Failed to read worktree config: {e}")
|
|
|
|
# Strategy 2: Find which branch has the closest merge-base
|
|
# Check common branches: develop, main, master
|
|
spec_branch = f"auto-claude/{spec_name}"
|
|
candidate_branches = ["develop", "main", "master"]
|
|
|
|
best_branch = None
|
|
best_commits_behind = float("inf")
|
|
|
|
for branch in candidate_branches:
|
|
try:
|
|
# Check if branch exists
|
|
check = subprocess.run(
|
|
["git", "rev-parse", "--verify", branch],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if check.returncode != 0:
|
|
continue
|
|
|
|
# Get merge base
|
|
merge_base_result = subprocess.run(
|
|
["git", "merge-base", branch, spec_branch],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if merge_base_result.returncode != 0:
|
|
continue
|
|
|
|
merge_base = merge_base_result.stdout.strip()
|
|
|
|
# Count commits between merge-base and branch tip
|
|
# The branch with fewer commits ahead is likely the one we branched from
|
|
ahead_result = subprocess.run(
|
|
["git", "rev-list", "--count", f"{merge_base}..{branch}"],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if ahead_result.returncode == 0:
|
|
commits_ahead = int(ahead_result.stdout.strip())
|
|
debug(
|
|
MODULE,
|
|
f"Branch {branch} is {commits_ahead} commits ahead of merge-base",
|
|
)
|
|
if commits_ahead < best_commits_behind:
|
|
best_commits_behind = commits_ahead
|
|
best_branch = branch
|
|
except Exception as e:
|
|
debug_warning(MODULE, f"Error checking branch {branch}: {e}")
|
|
continue
|
|
|
|
if best_branch:
|
|
debug(
|
|
MODULE,
|
|
f"Detected base branch from git history: {best_branch} (commits ahead: {best_commits_behind})",
|
|
)
|
|
return best_branch
|
|
|
|
return None
|
|
|
|
|
|
def _detect_parallel_task_conflicts(
|
|
project_dir: Path,
|
|
current_task_id: str,
|
|
current_task_files: list[str],
|
|
) -> list[dict]:
|
|
"""
|
|
Detect potential conflicts between this task and other active tasks.
|
|
|
|
Uses existing evolution data to check if any of this task's files
|
|
have been modified by other active tasks. This is a lightweight check
|
|
that doesn't require re-processing all files.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
current_task_id: ID of the current task
|
|
current_task_files: Files modified by this task (from git diff)
|
|
|
|
Returns:
|
|
List of conflict dictionaries with 'file' and 'tasks' keys
|
|
"""
|
|
try:
|
|
from merge import MergeOrchestrator
|
|
|
|
# Initialize orchestrator just to access evolution data
|
|
orchestrator = MergeOrchestrator(
|
|
project_dir,
|
|
enable_ai=False,
|
|
dry_run=True,
|
|
)
|
|
|
|
# Get all active tasks from evolution data
|
|
active_tasks = orchestrator.evolution_tracker.get_active_tasks()
|
|
|
|
# Remove current task from active tasks
|
|
other_active_tasks = active_tasks - {current_task_id}
|
|
|
|
if not other_active_tasks:
|
|
return []
|
|
|
|
# Convert current task files to a set for fast lookup
|
|
current_files_set = set(current_task_files)
|
|
|
|
# Get files modified by other active tasks
|
|
conflicts = []
|
|
other_task_files = orchestrator.evolution_tracker.get_files_modified_by_tasks(
|
|
list(other_active_tasks)
|
|
)
|
|
|
|
# Find intersection - files modified by both this task and other tasks
|
|
for file_path, tasks in other_task_files.items():
|
|
if file_path in current_files_set:
|
|
# This file was modified by both current task and other task(s)
|
|
all_tasks = [current_task_id] + tasks
|
|
conflicts.append({"file": file_path, "tasks": all_tasks})
|
|
|
|
return conflicts
|
|
|
|
except Exception as e:
|
|
# If anything fails, just return empty - parallel task detection is optional
|
|
debug_warning(
|
|
"workspace_commands",
|
|
f"Parallel task conflict detection failed: {e}",
|
|
)
|
|
return []
|
|
|
|
|
|
# Import debug utilities
|
|
try:
|
|
from debug import (
|
|
debug,
|
|
debug_detailed,
|
|
debug_error,
|
|
debug_section,
|
|
debug_success,
|
|
debug_verbose,
|
|
is_debug_enabled,
|
|
)
|
|
except ImportError:
|
|
|
|
def debug(*args, **kwargs):
|
|
"""Fallback debug function when debug module is not available."""
|
|
pass
|
|
|
|
def debug_detailed(*args, **kwargs):
|
|
"""Fallback debug_detailed function when debug module is not available."""
|
|
pass
|
|
|
|
def debug_verbose(*args, **kwargs):
|
|
"""Fallback debug_verbose function when debug module is not available."""
|
|
pass
|
|
|
|
def debug_success(*args, **kwargs):
|
|
"""Fallback debug_success function when debug module is not available."""
|
|
pass
|
|
|
|
def debug_error(*args, **kwargs):
|
|
"""Fallback debug_error function when debug module is not available."""
|
|
pass
|
|
|
|
def debug_section(*args, **kwargs):
|
|
"""Fallback debug_section function when debug module is not available."""
|
|
pass
|
|
|
|
def is_debug_enabled():
|
|
"""Fallback is_debug_enabled function when debug module is not available."""
|
|
return False
|
|
|
|
|
|
MODULE = "cli.workspace_commands"
|
|
|
|
|
|
def handle_merge_command(
|
|
project_dir: Path,
|
|
spec_name: str,
|
|
no_commit: bool = False,
|
|
base_branch: str | None = None,
|
|
) -> bool:
|
|
"""
|
|
Handle the --merge command.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
spec_name: Name of the spec
|
|
no_commit: If True, stage changes but don't commit
|
|
base_branch: Branch to compare against (default: auto-detect)
|
|
|
|
Returns:
|
|
True if merge succeeded, False otherwise
|
|
"""
|
|
success = merge_existing_build(
|
|
project_dir, spec_name, no_commit=no_commit, base_branch=base_branch
|
|
)
|
|
|
|
# Generate commit message suggestion if staging succeeded (no_commit mode)
|
|
if success and no_commit:
|
|
_generate_and_save_commit_message(project_dir, spec_name)
|
|
|
|
return success
|
|
|
|
|
|
def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None:
|
|
"""
|
|
Generate a commit message suggestion and save it for the UI.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
spec_name: Name of the spec
|
|
"""
|
|
try:
|
|
from commit_message import generate_commit_message_sync
|
|
|
|
# Get diff summary for context
|
|
diff_summary = ""
|
|
files_changed = []
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "diff", "--staged", "--stat"],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode == 0:
|
|
diff_summary = result.stdout.strip()
|
|
|
|
# Get list of changed files
|
|
result = subprocess.run(
|
|
["git", "diff", "--staged", "--name-only"],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode == 0:
|
|
files_changed = [
|
|
f.strip() for f in result.stdout.strip().split("\n") if f.strip()
|
|
]
|
|
except Exception as e:
|
|
debug_warning(MODULE, f"Could not get diff summary: {e}")
|
|
|
|
# Generate commit message
|
|
debug(MODULE, "Generating commit message suggestion...")
|
|
commit_message = generate_commit_message_sync(
|
|
project_dir=project_dir,
|
|
spec_name=spec_name,
|
|
diff_summary=diff_summary,
|
|
files_changed=files_changed,
|
|
)
|
|
|
|
if commit_message:
|
|
# Save to spec directory for UI to read
|
|
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
|
|
if not spec_dir.exists():
|
|
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
|
|
|
|
if spec_dir.exists():
|
|
commit_msg_file = spec_dir / "suggested_commit_message.txt"
|
|
commit_msg_file.write_text(commit_message, encoding="utf-8")
|
|
debug_success(
|
|
MODULE, f"Saved commit message suggestion to {commit_msg_file}"
|
|
)
|
|
else:
|
|
debug_warning(MODULE, f"Spec directory not found: {spec_dir}")
|
|
else:
|
|
debug_warning(MODULE, "No commit message generated")
|
|
|
|
except ImportError:
|
|
debug_warning(MODULE, "commit_message module not available")
|
|
except Exception as e:
|
|
debug_warning(MODULE, f"Failed to generate commit message: {e}")
|
|
|
|
|
|
def handle_review_command(project_dir: Path, spec_name: str) -> None:
|
|
"""
|
|
Handle the --review command.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
spec_name: Name of the spec
|
|
"""
|
|
review_existing_build(project_dir, spec_name)
|
|
|
|
|
|
def handle_discard_command(project_dir: Path, spec_name: str) -> None:
|
|
"""
|
|
Handle the --discard command.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
spec_name: Name of the spec
|
|
"""
|
|
discard_existing_build(project_dir, spec_name)
|
|
|
|
|
|
def handle_list_worktrees_command(project_dir: Path) -> None:
|
|
"""
|
|
Handle the --list-worktrees command.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
"""
|
|
print_banner()
|
|
print("\n" + "=" * 70)
|
|
print(" SPEC WORKTREES")
|
|
print("=" * 70)
|
|
print()
|
|
|
|
worktrees = list_all_worktrees(project_dir)
|
|
if not worktrees:
|
|
print(" No worktrees found.")
|
|
print()
|
|
print(" Worktrees are created when you run a build in isolated mode.")
|
|
else:
|
|
for wt in worktrees:
|
|
print(f" {icon(Icons.FOLDER)} {wt.spec_name}")
|
|
print(f" Branch: {wt.branch}")
|
|
print(f" Path: {wt.path}")
|
|
print(f" Commits: {wt.commit_count}, Files: {wt.files_changed}")
|
|
print()
|
|
|
|
print("-" * 70)
|
|
print()
|
|
print(" To merge: python auto-claude/run.py --spec <name> --merge")
|
|
print(" To review: python auto-claude/run.py --spec <name> --review")
|
|
print(" To discard: python auto-claude/run.py --spec <name> --discard")
|
|
print()
|
|
print(
|
|
" To cleanup all worktrees: python auto-claude/run.py --cleanup-worktrees"
|
|
)
|
|
print()
|
|
|
|
|
|
def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
|
"""
|
|
Handle the --cleanup-worktrees command.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
"""
|
|
print_banner()
|
|
cleanup_all_worktrees(project_dir, confirm=True)
|
|
|
|
|
|
def _check_git_merge_conflicts(
|
|
project_dir: Path, spec_name: str, base_branch: str | None = None
|
|
) -> dict:
|
|
"""
|
|
Check for git-level merge conflicts WITHOUT modifying the working directory.
|
|
|
|
Uses git merge-tree and git diff to detect conflicts in-memory,
|
|
which avoids triggering Vite HMR or other file watchers.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
spec_name: Name of the spec
|
|
base_branch: Branch the task was created from (default: auto-detect)
|
|
|
|
Returns:
|
|
Dictionary with git conflict information:
|
|
- has_conflicts: bool
|
|
- conflicting_files: list of file paths
|
|
- needs_rebase: bool (if main has advanced)
|
|
- base_branch: str
|
|
- spec_branch: str
|
|
"""
|
|
import subprocess
|
|
|
|
debug(MODULE, "Checking for git-level merge conflicts (non-destructive)...")
|
|
|
|
spec_branch = f"auto-claude/{spec_name}"
|
|
result = {
|
|
"has_conflicts": False,
|
|
"conflicting_files": [],
|
|
"needs_rebase": False,
|
|
"base_branch": base_branch or "main",
|
|
"spec_branch": spec_branch,
|
|
"commits_behind": 0,
|
|
}
|
|
|
|
try:
|
|
# Use provided base_branch, or detect from current HEAD
|
|
if not base_branch:
|
|
base_result = subprocess.run(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if base_result.returncode == 0:
|
|
result["base_branch"] = base_result.stdout.strip()
|
|
else:
|
|
result["base_branch"] = base_branch
|
|
debug(MODULE, f"Using provided base branch: {base_branch}")
|
|
|
|
# Get the merge base commit
|
|
merge_base_result = subprocess.run(
|
|
["git", "merge-base", result["base_branch"], spec_branch],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if merge_base_result.returncode != 0:
|
|
debug_warning(MODULE, "Could not find merge base")
|
|
return result
|
|
|
|
merge_base = merge_base_result.stdout.strip()
|
|
|
|
# Count commits main is ahead
|
|
ahead_result = subprocess.run(
|
|
["git", "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if ahead_result.returncode == 0:
|
|
commits_behind = int(ahead_result.stdout.strip())
|
|
result["commits_behind"] = commits_behind
|
|
if commits_behind > 0:
|
|
result["needs_rebase"] = True
|
|
debug(
|
|
MODULE, f"Main is {commits_behind} commits ahead of worktree base"
|
|
)
|
|
|
|
# Use git merge-tree to check for conflicts WITHOUT touching working directory
|
|
# This is a plumbing command that does a 3-way merge in memory
|
|
# Note: --write-tree mode only accepts 2 branches (it auto-finds the merge base)
|
|
merge_tree_result = subprocess.run(
|
|
[
|
|
"git",
|
|
"merge-tree",
|
|
"--write-tree",
|
|
"--no-messages",
|
|
result["base_branch"], # Use branch names, not commit hashes
|
|
spec_branch,
|
|
],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
# merge-tree returns exit code 1 if there are conflicts
|
|
if merge_tree_result.returncode != 0:
|
|
result["has_conflicts"] = True
|
|
debug(MODULE, "Git merge-tree detected conflicts")
|
|
|
|
# Parse the output for conflicting files
|
|
# merge-tree --write-tree outputs conflict info to stderr
|
|
output = merge_tree_result.stdout + merge_tree_result.stderr
|
|
for line in output.split("\n"):
|
|
# Look for lines indicating conflicts
|
|
if "CONFLICT" in line:
|
|
# Extract file path from conflict message
|
|
import re
|
|
|
|
match = re.search(
|
|
r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()",
|
|
line,
|
|
)
|
|
if match:
|
|
file_path = match.group(1).strip()
|
|
# Skip .auto-claude files - they should never be merged
|
|
if (
|
|
file_path
|
|
and file_path not in result["conflicting_files"]
|
|
and not _is_auto_claude_file(file_path)
|
|
):
|
|
result["conflicting_files"].append(file_path)
|
|
|
|
# Fallback: if we didn't parse conflicts, use diff to find files changed in both branches
|
|
if not result["conflicting_files"]:
|
|
# Files changed in main since merge-base
|
|
main_files_result = subprocess.run(
|
|
["git", "diff", "--name-only", merge_base, result["base_branch"]],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
main_files = (
|
|
set(main_files_result.stdout.strip().split("\n"))
|
|
if main_files_result.stdout.strip()
|
|
else set()
|
|
)
|
|
|
|
# Files changed in spec branch since merge-base
|
|
spec_files_result = subprocess.run(
|
|
["git", "diff", "--name-only", merge_base, spec_branch],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
spec_files = (
|
|
set(spec_files_result.stdout.strip().split("\n"))
|
|
if spec_files_result.stdout.strip()
|
|
else set()
|
|
)
|
|
|
|
# Files modified in both = potential conflicts
|
|
# Filter out .auto-claude files - they should never be merged
|
|
conflicting = main_files & spec_files
|
|
result["conflicting_files"] = [
|
|
f for f in conflicting if not _is_auto_claude_file(f)
|
|
]
|
|
debug(
|
|
MODULE, f"Found {len(conflicting)} files modified in both branches"
|
|
)
|
|
|
|
debug(MODULE, f"Conflicting files: {result['conflicting_files']}")
|
|
else:
|
|
debug_success(MODULE, "Git merge-tree: no conflicts detected")
|
|
|
|
except Exception as e:
|
|
debug_error(MODULE, f"Error checking git conflicts: {e}")
|
|
import traceback
|
|
|
|
debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc())
|
|
|
|
return result
|
|
|
|
|
|
def handle_merge_preview_command(
|
|
project_dir: Path,
|
|
spec_name: str,
|
|
base_branch: str | None = None,
|
|
) -> dict:
|
|
"""
|
|
Handle the --merge-preview command.
|
|
|
|
Returns a JSON-serializable preview of merge conflicts without
|
|
actually performing the merge. This is used by the UI to show
|
|
potential conflicts before the user clicks "Stage Changes".
|
|
|
|
This checks for TWO types of conflicts:
|
|
1. Semantic conflicts: Multiple parallel tasks modifying the same code
|
|
2. Git conflicts: Main branch has diverged from worktree branch
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
spec_name: Name of the spec
|
|
base_branch: Branch the task was created from (for comparison). If None, auto-detect.
|
|
|
|
Returns:
|
|
Dictionary with preview information
|
|
"""
|
|
debug_section(MODULE, "Merge Preview Command")
|
|
debug(
|
|
MODULE,
|
|
"handle_merge_preview_command() called",
|
|
project_dir=str(project_dir),
|
|
spec_name=spec_name,
|
|
)
|
|
|
|
from workspace import get_existing_build_worktree
|
|
|
|
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
|
debug(
|
|
MODULE,
|
|
"Worktree lookup result",
|
|
worktree_path=str(worktree_path) if worktree_path else None,
|
|
)
|
|
|
|
if not worktree_path:
|
|
debug_error(MODULE, f"No existing build found for '{spec_name}'")
|
|
return {
|
|
"success": False,
|
|
"error": f"No existing build found for '{spec_name}'",
|
|
"files": [],
|
|
"conflicts": [],
|
|
"gitConflicts": None,
|
|
"summary": {
|
|
"totalFiles": 0,
|
|
"conflictFiles": 0,
|
|
"totalConflicts": 0,
|
|
"autoMergeable": 0,
|
|
},
|
|
}
|
|
|
|
try:
|
|
# Determine the task's source branch (where the task was created from)
|
|
# Priority:
|
|
# 1. Provided base_branch (from task metadata)
|
|
# 2. Detect from worktree's git history (find which branch it diverged from)
|
|
# 3. Fall back to default branch detection (main/master)
|
|
task_source_branch = base_branch
|
|
if not task_source_branch:
|
|
# Try to detect from worktree's git history
|
|
task_source_branch = _detect_worktree_base_branch(
|
|
project_dir, worktree_path, spec_name
|
|
)
|
|
if not task_source_branch:
|
|
# Fall back to auto-detecting main/master
|
|
task_source_branch = _detect_default_branch(project_dir)
|
|
|
|
debug(
|
|
MODULE,
|
|
f"Using task source branch: {task_source_branch}",
|
|
provided=base_branch is not None,
|
|
)
|
|
|
|
# Check for git-level conflicts (diverged branches) using the task's source branch
|
|
git_conflicts = _check_git_merge_conflicts(
|
|
project_dir, spec_name, base_branch=task_source_branch
|
|
)
|
|
|
|
# Get actual changed files from git diff (this is the authoritative count)
|
|
all_changed_files = _get_changed_files_from_git(
|
|
worktree_path, task_source_branch
|
|
)
|
|
debug(
|
|
MODULE,
|
|
f"Git diff against '{task_source_branch}' shows {len(all_changed_files)} changed files",
|
|
changed_files=all_changed_files[:10], # Log first 10
|
|
)
|
|
|
|
# OPTIMIZATION: Skip expensive refresh_from_git() and preview_merge() calls
|
|
# For merge-preview, we only need to detect:
|
|
# 1. Git conflicts (task vs base branch) - already calculated in _check_git_merge_conflicts()
|
|
# 2. Parallel task conflicts (this task vs other active tasks)
|
|
#
|
|
# For parallel task detection, we just check if this task's files overlap
|
|
# with files OTHER tasks have already recorded - no need to re-process all files.
|
|
|
|
debug(MODULE, "Checking for parallel task conflicts (lightweight)...")
|
|
|
|
# Check for parallel task conflicts by looking at existing evolution data
|
|
parallel_conflicts = _detect_parallel_task_conflicts(
|
|
project_dir, spec_name, all_changed_files
|
|
)
|
|
debug(
|
|
MODULE,
|
|
f"Parallel task conflicts detected: {len(parallel_conflicts)}",
|
|
conflicts=parallel_conflicts[:5] if parallel_conflicts else [],
|
|
)
|
|
|
|
# Build conflict list - start with parallel task conflicts
|
|
conflicts = []
|
|
for pc in parallel_conflicts:
|
|
conflicts.append(
|
|
{
|
|
"file": pc["file"],
|
|
"location": "file-level",
|
|
"tasks": pc["tasks"],
|
|
"severity": "medium",
|
|
"canAutoMerge": False,
|
|
"strategy": None,
|
|
"reason": f"File modified by multiple active tasks: {', '.join(pc['tasks'])}",
|
|
"type": "parallel",
|
|
}
|
|
)
|
|
|
|
# Add git conflicts to the list (excluding lock files which are handled automatically)
|
|
lock_files_excluded = []
|
|
for file_path in git_conflicts.get("conflicting_files", []):
|
|
if is_lock_file(file_path):
|
|
# Lock files are auto-generated and should not go through AI merge
|
|
# They will be handled automatically by taking the worktree version
|
|
lock_files_excluded.append(file_path)
|
|
debug(MODULE, f"Excluding lock file from conflicts: {file_path}")
|
|
continue
|
|
|
|
conflicts.append(
|
|
{
|
|
"file": file_path,
|
|
"location": "file-level",
|
|
"tasks": [spec_name, git_conflicts["base_branch"]],
|
|
"severity": "high",
|
|
"canAutoMerge": False,
|
|
"strategy": None,
|
|
"reason": f"File modified in both {git_conflicts['base_branch']} and worktree since branch point",
|
|
"type": "git",
|
|
}
|
|
)
|
|
|
|
# Count only non-lock-file conflicts
|
|
git_conflict_count = len(git_conflicts.get("conflicting_files", [])) - len(
|
|
lock_files_excluded
|
|
)
|
|
# Calculate totals from our conflict lists (git conflicts + parallel conflicts)
|
|
parallel_conflict_count = len(parallel_conflicts)
|
|
total_conflicts = git_conflict_count + parallel_conflict_count
|
|
conflict_files = git_conflict_count + parallel_conflict_count
|
|
|
|
# Filter lock files from the git conflicts list for the response
|
|
non_lock_conflicting_files = [
|
|
f for f in git_conflicts.get("conflicting_files", []) if not is_lock_file(f)
|
|
]
|
|
|
|
# Use git diff file count as the authoritative totalFiles count
|
|
# The semantic tracker may not track all files (e.g., test files, config files)
|
|
# but we want to show the user all files that will be merged
|
|
total_files_from_git = len(all_changed_files)
|
|
|
|
# Detect files that need AI merge due to path mappings (file renames)
|
|
# This happens when the target branch has renamed/moved files that the
|
|
# worktree modified at their old locations
|
|
path_mapped_ai_merges: list[dict] = []
|
|
path_mappings: dict[str, str] = {}
|
|
|
|
if git_conflicts["needs_rebase"] and git_conflicts["commits_behind"] > 0:
|
|
# Get the merge-base between the branches
|
|
spec_branch = git_conflicts["spec_branch"]
|
|
base_branch = git_conflicts["base_branch"]
|
|
merge_base = get_merge_base(project_dir, spec_branch, base_branch)
|
|
|
|
if merge_base:
|
|
# Detect file renames between merge-base and current base branch
|
|
path_mappings = detect_file_renames(
|
|
project_dir, merge_base, base_branch
|
|
)
|
|
|
|
if path_mappings:
|
|
debug(
|
|
MODULE,
|
|
f"Detected {len(path_mappings)} file rename(s) between merge-base and target",
|
|
sample_mappings={
|
|
k: v for k, v in list(path_mappings.items())[:3]
|
|
},
|
|
)
|
|
|
|
# Check which changed files have path mappings and need AI merge
|
|
for file_path in all_changed_files:
|
|
mapped_path = apply_path_mapping(file_path, path_mappings)
|
|
if mapped_path != file_path:
|
|
# File was renamed - check if both versions exist
|
|
worktree_content = get_file_content_from_ref(
|
|
project_dir, spec_branch, file_path
|
|
)
|
|
target_content = get_file_content_from_ref(
|
|
project_dir, base_branch, mapped_path
|
|
)
|
|
|
|
if worktree_content and target_content:
|
|
path_mapped_ai_merges.append(
|
|
{
|
|
"oldPath": file_path,
|
|
"newPath": mapped_path,
|
|
"reason": "File was renamed/moved and modified in both branches",
|
|
}
|
|
)
|
|
debug(
|
|
MODULE,
|
|
f"Path-mapped file needs AI merge: {file_path} -> {mapped_path}",
|
|
)
|
|
|
|
result = {
|
|
"success": True,
|
|
# Use git diff files as the authoritative list of files to merge
|
|
"files": all_changed_files,
|
|
"conflicts": conflicts,
|
|
"gitConflicts": {
|
|
"hasConflicts": git_conflicts["has_conflicts"]
|
|
and len(non_lock_conflicting_files) > 0,
|
|
"conflictingFiles": non_lock_conflicting_files,
|
|
"needsRebase": git_conflicts["needs_rebase"],
|
|
"commitsBehind": git_conflicts["commits_behind"],
|
|
"baseBranch": git_conflicts["base_branch"],
|
|
"specBranch": git_conflicts["spec_branch"],
|
|
# Path-mapped files that need AI merge due to renames
|
|
"pathMappedAIMerges": path_mapped_ai_merges,
|
|
"totalRenames": len(path_mappings),
|
|
},
|
|
"summary": {
|
|
# Use git diff count, not semantic tracker count
|
|
"totalFiles": total_files_from_git,
|
|
"conflictFiles": conflict_files,
|
|
"totalConflicts": total_conflicts,
|
|
"autoMergeable": 0, # Not tracking auto-merge in lightweight mode
|
|
"hasGitConflicts": git_conflicts["has_conflicts"]
|
|
and len(non_lock_conflicting_files) > 0,
|
|
# Include path-mapped AI merge count for UI display
|
|
"pathMappedAIMergeCount": len(path_mapped_ai_merges),
|
|
},
|
|
# Include lock files info so UI can optionally show them
|
|
"lockFilesExcluded": lock_files_excluded,
|
|
}
|
|
|
|
debug_success(
|
|
MODULE,
|
|
"Merge preview complete",
|
|
total_files=result["summary"]["totalFiles"],
|
|
total_files_source="git_diff",
|
|
total_conflicts=result["summary"]["totalConflicts"],
|
|
has_git_conflicts=git_conflicts["has_conflicts"],
|
|
parallel_conflicts=parallel_conflict_count,
|
|
path_mapped_ai_merges=len(path_mapped_ai_merges),
|
|
total_renames=len(path_mappings),
|
|
)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
debug_error(MODULE, "Merge preview failed", error=str(e))
|
|
import traceback
|
|
|
|
debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc())
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"files": [],
|
|
"conflicts": [],
|
|
"gitConflicts": None,
|
|
"summary": {
|
|
"totalFiles": 0,
|
|
"conflictFiles": 0,
|
|
"totalConflicts": 0,
|
|
"autoMergeable": 0,
|
|
"pathMappedAIMergeCount": 0,
|
|
},
|
|
}
|
|
|
|
|
|
def handle_create_pr_command(
|
|
project_dir: Path,
|
|
spec_name: str,
|
|
target_branch: str | None = None,
|
|
title: str | None = None,
|
|
draft: bool = False,
|
|
) -> CreatePRResult:
|
|
"""
|
|
Handle the --create-pr command: push branch and create a GitHub PR.
|
|
|
|
Args:
|
|
project_dir: Path to the project directory
|
|
spec_name: Name of the spec (e.g., "001-feature-name")
|
|
target_branch: Target branch for PR (defaults to base branch)
|
|
title: Custom PR title (defaults to spec name)
|
|
draft: Whether to create as draft PR
|
|
|
|
Returns:
|
|
CreatePRResult with success status, pr_url, and any errors
|
|
"""
|
|
from core.worktree import WorktreeManager
|
|
|
|
print_banner()
|
|
print("\n" + "=" * 70)
|
|
print(" CREATE PULL REQUEST")
|
|
print("=" * 70)
|
|
|
|
# Check if worktree exists
|
|
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
|
if not worktree_path:
|
|
print(f"\n{icon(Icons.ERROR)} No build found for spec: {spec_name}")
|
|
print("\nA completed build worktree is required to create a PR.")
|
|
print("Run your build first, then use --create-pr.")
|
|
error_result: CreatePRResult = {
|
|
"success": False,
|
|
"error": "No build found for this spec",
|
|
}
|
|
return error_result
|
|
|
|
# Create worktree manager
|
|
manager = WorktreeManager(project_dir, base_branch=target_branch)
|
|
|
|
print(f"\n{icon(Icons.BRANCH)} Pushing branch and creating PR...")
|
|
print(f" Spec: {spec_name}")
|
|
print(f" Target: {target_branch or manager.base_branch}")
|
|
if title:
|
|
print(f" Title: {title}")
|
|
if draft:
|
|
print(" Mode: Draft PR")
|
|
|
|
# Push and create PR with exception handling for clean JSON output
|
|
try:
|
|
raw_result = manager.push_and_create_pr(
|
|
spec_name=spec_name,
|
|
target_branch=target_branch,
|
|
title=title,
|
|
draft=draft,
|
|
)
|
|
except Exception as e:
|
|
debug_error(MODULE, f"Exception during PR creation: {e}")
|
|
error_result: CreatePRResult = {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to create PR",
|
|
}
|
|
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {e}")
|
|
print(json.dumps(error_result))
|
|
return error_result
|
|
|
|
# Convert PushAndCreatePRResult to CreatePRResult
|
|
result: CreatePRResult = {
|
|
"success": raw_result.get("success", False),
|
|
"pr_url": raw_result.get("pr_url"),
|
|
"already_exists": raw_result.get("already_exists", False),
|
|
"error": raw_result.get("error"),
|
|
"message": raw_result.get("message"),
|
|
"pushed": raw_result.get("pushed", False),
|
|
"remote": raw_result.get("remote", ""),
|
|
"branch": raw_result.get("branch", ""),
|
|
}
|
|
|
|
if result.get("success"):
|
|
pr_url = result.get("pr_url")
|
|
already_exists = result.get("already_exists", False)
|
|
|
|
if already_exists:
|
|
print(f"\n{icon(Icons.SUCCESS)} PR already exists!")
|
|
else:
|
|
print(f"\n{icon(Icons.SUCCESS)} PR created successfully!")
|
|
|
|
if pr_url:
|
|
print(f"\n{icon(Icons.LINK)} {pr_url}")
|
|
else:
|
|
print(f"\n{icon(Icons.INFO)} Check GitHub for the PR URL")
|
|
|
|
print("\nNext steps:")
|
|
print(" 1. Review the PR on GitHub")
|
|
print(" 2. Request reviews from your team")
|
|
print(" 3. Merge when approved")
|
|
|
|
# Output JSON for frontend parsing
|
|
print(json.dumps(result))
|
|
return result
|
|
else:
|
|
error = result.get("error", "Unknown error")
|
|
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {error}")
|
|
# Output JSON for frontend parsing
|
|
print(json.dumps(result))
|
|
return result
|
|
|
|
|
|
def cleanup_old_worktrees_command(
|
|
project_dir: Path, days: int = 30, dry_run: bool = False
|
|
) -> dict:
|
|
"""
|
|
Clean up old worktrees that haven't been modified in the specified number of days.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
days: Number of days threshold (default: 30)
|
|
dry_run: If True, only show what would be removed (default: False)
|
|
|
|
Returns:
|
|
Dictionary with cleanup results
|
|
"""
|
|
try:
|
|
manager = WorktreeManager(project_dir)
|
|
|
|
removed, failed = manager.cleanup_old_worktrees(
|
|
days_threshold=days, dry_run=dry_run
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"removed": removed,
|
|
"failed": failed,
|
|
"dry_run": dry_run,
|
|
"days_threshold": days,
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"removed": [],
|
|
"failed": [],
|
|
}
|
|
|
|
|
|
def worktree_summary_command(project_dir: Path) -> dict:
|
|
"""
|
|
Get a summary of all worktrees with age information.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
|
|
Returns:
|
|
Dictionary with worktree summary data
|
|
"""
|
|
try:
|
|
manager = WorktreeManager(project_dir)
|
|
|
|
# Print to console for CLI usage
|
|
manager.print_worktree_summary()
|
|
|
|
# Also return data for programmatic access
|
|
worktrees = manager.list_all_worktrees()
|
|
warning = manager.get_worktree_count_warning()
|
|
|
|
# Categorize by age
|
|
recent = []
|
|
week_old = []
|
|
month_old = []
|
|
very_old = []
|
|
unknown_age = []
|
|
|
|
for info in worktrees:
|
|
data = {
|
|
"spec_name": info.spec_name,
|
|
"days_since_last_commit": info.days_since_last_commit,
|
|
"commit_count": info.commit_count,
|
|
}
|
|
|
|
if info.days_since_last_commit is None:
|
|
unknown_age.append(data)
|
|
elif info.days_since_last_commit < 7:
|
|
recent.append(data)
|
|
elif info.days_since_last_commit < 30:
|
|
week_old.append(data)
|
|
elif info.days_since_last_commit < 90:
|
|
month_old.append(data)
|
|
else:
|
|
very_old.append(data)
|
|
|
|
return {
|
|
"success": True,
|
|
"total_worktrees": len(worktrees),
|
|
"categories": {
|
|
"recent": recent,
|
|
"week_old": week_old,
|
|
"month_old": month_old,
|
|
"very_old": very_old,
|
|
"unknown_age": unknown_age,
|
|
},
|
|
"warning": warning,
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"total_worktrees": 0,
|
|
"categories": {},
|
|
"warning": None,
|
|
}
|