feat(terminal): add worktree support for terminals (#625)
* feat/worktree-in-terminal * feat(terminal): add worktree selector dropdown and fix PTY recreation - Add WorktreeSelector dropdown to choose existing worktrees or create new - Fix terminal "process exited with code 1" when creating worktree by properly resetting usePtyProcess refs via resetForRecreate() - Use effectiveCwd from store to detect cwd changes for PTY recreation - Read project settings mainBranch for default branch instead of auto-detect - Add i18n translations for worktree selector (en/fr) The PTY recreation issue was caused by usePtyProcess having its own internal refs that weren't reset when Terminal.tsx destroyed the PTY. Now the hook exposes resetForRecreate() and tracks cwd changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(github): prevent follow-up review from analyzing merge-introduced commits Follow-up PR reviews were incorrectly flagging issues from OTHER PRs when authors merged develop/main into their feature branch. The commit comparison included all commits in the merge, not just the PR's own work. Changes: - Add get_pr_files() and get_pr_commits() to gh_client.py to fetch PR-scoped data from GitHub's PR endpoints (excludes merge-introduced changes) - Update FollowupContextGatherer to use PR-scoped methods with fallback - Add nuanced "PR Scope and Context" guidance to all review agent prompts distinguishing between: - In scope: issues in changed code, impact on other code, missing updates - Out of scope: pre-existing bugs, code from other PRs via merge commits The prompts now allow reviewers to flag "you changed X but forgot Y" while rejecting "old code in legacy.ts has a bug" false positives. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(github): add merge conflict detection to PR reviews AI reviewers were not detecting when PRs had merge conflicts with the base branch. Now both initial and follow-up reviews check for conflicts via GitHub's mergeable status and report them as CRITICAL findings. Changes: - Add has_merge_conflicts and merge_state_status fields to PRContext and FollowupReviewContext - Fetch mergeable and mergeStateStatus from GitHub API - Update orchestrator prompts to instruct AI to report conflicts prominently with category "merge_conflict" and severity "critical" Note: GitHub API only reports IF conflicts exist, not WHICH files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: remove obsolete staging tests after worktree consolidation Remove tests for staging methods that were removed during the worktree storage consolidation refactor: - Remove entire TestStagingWorktree class - Remove test_remove_staging test - Remove staging-related tests from TestWorktreeCommitAndMerge - Update TestChangeTracking tests to use create_worktree - Update TestWorktreeUtilities tests to use create_worktree 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * security: fix command injection and improve validation in worktree handlers CRITICAL: - Add GIT_BRANCH_REGEX validation for baseBranch to prevent command injection - Replace execSync with execFileSync to eliminate shell interpretation HIGH: - Add name validation in removeTerminalWorktree to prevent path traversal - Fix race condition in handleWorktreeCreated by adding prepareForRecreate MEDIUM: - Add projectPath validation against registered projects - Add try-catch for getDefaultBranch fallback - Add cleanup logic on worktree creation failure - Add logging for config.json parse errors LOW: - Remove unused recreateRequestedRef from usePtyProcess - Add toast notification for IDE launch failures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add legacy fallback to get_existing_build_worktree The function was only checking the new path but missing the legacy fallback for existing worktrees at .worktrees/. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: update test to check new worktree path The test was checking for legacy .worktrees/ directory but worktrees are now created at .auto-claude/worktrees/tasks/. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: fix workspace tests for new worktree path structure - Update path assertions to use .auto-claude/worktrees/tasks/ - Replace removed staging methods (commit_in_staging, merge_staging) with direct git subprocess commands and merge_worktree 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review findings - Fix SHA prefix comparison using consistent 7-char minimum (git default) - Remove unused pr_commit_shas variable (dead code) - Add git worktree prune to cleanup for stale registrations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract worktree path constants to shared module - Create worktree-paths.ts with centralized constants and helpers - Remove duplicate TASK_WORKTREE_DIR from 4 files - Remove duplicate TERMINAL_WORKTREE_DIR from terminal handlers - Add legacy path fallback support in shared helpers - Add branch name re-validation in removeTerminalWorktree (defense in depth) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: rename escapeAppleScriptPath to escapeSingleQuotedPath Function is used for both AppleScript and shell contexts - the new name better reflects that it escapes single quotes for any single-quoted string. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(github): add blob SHA comparison for rebase-resistant follow-up reviews When a PR is rebased or force-pushed, commit SHAs change but file content blob SHAs persist. This feature stores blob SHAs during initial review and uses them to detect which files actually changed content when the old commit SHA is no longer found in the PR history. Changes: - Add reviewed_file_blobs field to PRReviewResult model - Update get_pr_files_changed_since with blob comparison fallback - Capture file blobs in all reviewer implementations - Pass blob data through context gatherer for follow-ups - Update TypeScript types and IPC handler mapping This prevents unnecessary re-review of unchanged files after rebases, improving follow-up review efficiency and accuracy. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -184,7 +184,7 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
True if successful
|
||||
"""
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
worktrees_dir = Path(project_dir) / ".worktrees"
|
||||
worktrees_dir = Path(project_dir) / ".auto-claude" / "worktrees" / "tasks"
|
||||
|
||||
if not specs_dir.exists():
|
||||
print_status("No specs directory found", "info")
|
||||
@@ -209,7 +209,7 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
print(f" - {spec_name}")
|
||||
wt_path = worktrees_dir / spec_name
|
||||
if wt_path.exists():
|
||||
print(f" └─ .worktrees/{spec_name}/")
|
||||
print(f" └─ .auto-claude/worktrees/tasks/{spec_name}/")
|
||||
print()
|
||||
print("Run with --no-dry-run to actually delete")
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
|
||||
return spec_folder
|
||||
|
||||
# Check worktree specs (for merge-preview, merge, review, discard operations)
|
||||
worktree_base = project_dir / ".worktrees"
|
||||
worktree_base = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
if worktree_base.exists():
|
||||
# Try exact match in worktree
|
||||
worktree_spec = (
|
||||
|
||||
@@ -4,7 +4,7 @@ Workspace Management - Per-Spec Architecture
|
||||
=============================================
|
||||
|
||||
Handles workspace isolation through Git worktrees, where each spec
|
||||
gets its own isolated worktree in .worktrees/{spec-name}/.
|
||||
gets its own isolated worktree in .auto-claude/worktrees/tasks/{spec-name}/.
|
||||
|
||||
This module has been refactored for better maintainability:
|
||||
- Models and enums: workspace/models.py
|
||||
|
||||
@@ -4,7 +4,7 @@ Workspace Management Package
|
||||
=============================
|
||||
|
||||
Handles workspace isolation through Git worktrees, where each spec
|
||||
gets its own isolated worktree in .worktrees/{spec-name}/.
|
||||
gets its own isolated worktree in .auto-claude/worktrees/tasks/{spec-name}/.
|
||||
|
||||
This package provides:
|
||||
- Workspace setup and configuration
|
||||
|
||||
@@ -169,7 +169,15 @@ def handle_workspace_choice(
|
||||
if staging_path:
|
||||
print(highlight(f" cd {staging_path}"))
|
||||
else:
|
||||
print(highlight(f" cd {project_dir}/.worktrees/{spec_name}"))
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if worktree_path:
|
||||
print(highlight(f" cd {worktree_path}"))
|
||||
else:
|
||||
print(
|
||||
highlight(
|
||||
f" cd {project_dir}/.auto-claude/worktrees/tasks/{spec_name}"
|
||||
)
|
||||
)
|
||||
|
||||
# Show likely test/run commands
|
||||
if staging_path:
|
||||
@@ -232,7 +240,15 @@ def handle_workspace_choice(
|
||||
if staging_path:
|
||||
print(highlight(f" cd {staging_path}"))
|
||||
else:
|
||||
print(highlight(f" cd {project_dir}/.worktrees/{spec_name}"))
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if worktree_path:
|
||||
print(highlight(f" cd {worktree_path}"))
|
||||
else:
|
||||
print(
|
||||
highlight(
|
||||
f" cd {project_dir}/.auto-claude/worktrees/tasks/{spec_name}"
|
||||
)
|
||||
)
|
||||
print()
|
||||
print("When you're ready to add it:")
|
||||
print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge"))
|
||||
|
||||
@@ -222,10 +222,16 @@ def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Path | Non
|
||||
Returns:
|
||||
Path to the worktree if it exists for this spec, None otherwise
|
||||
"""
|
||||
# Per-spec worktree path: .worktrees/{spec-name}/
|
||||
worktree_path = project_dir / ".worktrees" / spec_name
|
||||
if worktree_path.exists():
|
||||
return worktree_path
|
||||
# New path first
|
||||
new_path = project_dir / ".auto-claude" / "worktrees" / "tasks" / spec_name
|
||||
if new_path.exists():
|
||||
return new_path
|
||||
|
||||
# Legacy fallback
|
||||
legacy_path = project_dir / ".worktrees" / spec_name
|
||||
if legacy_path.exists():
|
||||
return legacy_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ class SpecNumberLock:
|
||||
max_number = max(max_number, self._scan_specs_dir(main_specs_dir))
|
||||
|
||||
# 2. Scan all worktree specs
|
||||
worktrees_dir = self.project_dir / ".worktrees"
|
||||
worktrees_dir = self.project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
if worktrees_dir.exists():
|
||||
for worktree in worktrees_dir.iterdir():
|
||||
if worktree.is_dir():
|
||||
|
||||
@@ -4,7 +4,7 @@ Git Worktree Manager - Per-Spec Architecture
|
||||
=============================================
|
||||
|
||||
Each spec gets its own worktree:
|
||||
- Worktree path: .worktrees/{spec-name}/
|
||||
- Worktree path: .auto-claude/worktrees/tasks/{spec-name}/
|
||||
- Branch name: auto-claude/{spec-name}
|
||||
|
||||
This allows:
|
||||
@@ -48,14 +48,14 @@ class WorktreeManager:
|
||||
"""
|
||||
Manages per-spec Git worktrees.
|
||||
|
||||
Each spec gets its own worktree in .worktrees/{spec-name}/ with
|
||||
Each spec gets its own worktree in .auto-claude/worktrees/tasks/{spec-name}/ with
|
||||
a corresponding branch auto-claude/{spec-name}.
|
||||
"""
|
||||
|
||||
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 / ".worktrees"
|
||||
self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
self._merge_lock = asyncio.Lock()
|
||||
|
||||
def _detect_base_branch(self) -> str:
|
||||
@@ -194,7 +194,7 @@ class WorktreeManager:
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create worktrees directory if needed."""
|
||||
self.worktrees_dir.mkdir(exist_ok=True)
|
||||
self.worktrees_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ==================== Per-Spec Worktree Methods ====================
|
||||
|
||||
@@ -478,14 +478,12 @@ class WorktreeManager:
|
||||
"""List all spec worktrees."""
|
||||
worktrees = []
|
||||
|
||||
if not self.worktrees_dir.exists():
|
||||
return worktrees
|
||||
|
||||
for item in self.worktrees_dir.iterdir():
|
||||
if item.is_dir():
|
||||
info = self.get_worktree_info(item.name)
|
||||
if info:
|
||||
worktrees.append(info)
|
||||
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)
|
||||
|
||||
return worktrees
|
||||
|
||||
@@ -587,81 +585,12 @@ class WorktreeManager:
|
||||
|
||||
return commands
|
||||
|
||||
# ==================== Backward Compatibility ====================
|
||||
# These methods provide backward compatibility with the old single-worktree API
|
||||
|
||||
def get_staging_path(self) -> Path | None:
|
||||
"""
|
||||
Backward compatibility: Get path to any existing spec worktree.
|
||||
Prefer using get_worktree_path(spec_name) instead.
|
||||
"""
|
||||
worktrees = self.list_all_worktrees()
|
||||
if worktrees:
|
||||
return worktrees[0].path
|
||||
return None
|
||||
|
||||
def get_staging_info(self) -> WorktreeInfo | None:
|
||||
"""
|
||||
Backward compatibility: Get info about any existing spec worktree.
|
||||
Prefer using get_worktree_info(spec_name) instead.
|
||||
"""
|
||||
worktrees = self.list_all_worktrees()
|
||||
if worktrees:
|
||||
return worktrees[0]
|
||||
return None
|
||||
|
||||
def merge_staging(self, delete_after: bool = True) -> bool:
|
||||
"""
|
||||
Backward compatibility: Merge first found worktree.
|
||||
Prefer using merge_worktree(spec_name) instead.
|
||||
"""
|
||||
worktrees = self.list_all_worktrees()
|
||||
if worktrees:
|
||||
return self.merge_worktree(worktrees[0].spec_name, delete_after)
|
||||
return False
|
||||
|
||||
def remove_staging(self, delete_branch: bool = True) -> None:
|
||||
"""
|
||||
Backward compatibility: Remove first found worktree.
|
||||
Prefer using remove_worktree(spec_name) instead.
|
||||
"""
|
||||
worktrees = self.list_all_worktrees()
|
||||
if worktrees:
|
||||
self.remove_worktree(worktrees[0].spec_name, delete_branch)
|
||||
|
||||
def get_or_create_staging(self, spec_name: str) -> WorktreeInfo:
|
||||
"""
|
||||
Backward compatibility: Alias for get_or_create_worktree.
|
||||
"""
|
||||
return self.get_or_create_worktree(spec_name)
|
||||
|
||||
def staging_exists(self) -> bool:
|
||||
"""
|
||||
Backward compatibility: Check if any spec worktree exists.
|
||||
Prefer using worktree_exists(spec_name) instead.
|
||||
"""
|
||||
return len(self.list_all_worktrees()) > 0
|
||||
|
||||
def commit_in_staging(self, message: str) -> bool:
|
||||
"""
|
||||
Backward compatibility: Commit in first found worktree.
|
||||
Prefer using commit_in_worktree(spec_name, message) instead.
|
||||
"""
|
||||
worktrees = self.list_all_worktrees()
|
||||
if worktrees:
|
||||
return self.commit_in_worktree(worktrees[0].spec_name, message)
|
||||
return False
|
||||
|
||||
def has_uncommitted_changes(self, in_staging: bool = False) -> bool:
|
||||
def has_uncommitted_changes(self, spec_name: str | None = None) -> bool:
|
||||
"""Check if there are uncommitted changes."""
|
||||
worktrees = self.list_all_worktrees()
|
||||
if in_staging and worktrees:
|
||||
cwd = worktrees[0].path
|
||||
else:
|
||||
cwd = None
|
||||
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())
|
||||
|
||||
|
||||
# Keep STAGING_WORKTREE_NAME for backward compatibility in imports
|
||||
STAGING_WORKTREE_NAME = "auto-claude"
|
||||
|
||||
@@ -27,29 +27,11 @@ def find_worktree(project_dir: Path, task_id: str) -> Path | None:
|
||||
Returns:
|
||||
Path to the worktree, or None if not found
|
||||
"""
|
||||
# Check common locations
|
||||
worktrees_dir = project_dir / ".worktrees"
|
||||
worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
if worktrees_dir.exists():
|
||||
# Look for worktree with task_id in name
|
||||
for entry in worktrees_dir.iterdir():
|
||||
if entry.is_dir() and task_id in entry.name:
|
||||
return entry
|
||||
|
||||
# Try git worktree list
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
for line in result.stdout.split("\n"):
|
||||
if line.startswith("worktree ") and task_id in line:
|
||||
return Path(line.split(" ", 1)[1])
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -189,7 +189,14 @@ class TimelineGitHelper:
|
||||
task_id.replace("task-", "") if task_id.startswith("task-") else task_id
|
||||
)
|
||||
|
||||
worktree_path = self.project_path / ".worktrees" / spec_name / file_path
|
||||
worktree_path = (
|
||||
self.project_path
|
||||
/ ".auto-claude"
|
||||
/ "worktrees"
|
||||
/ "tasks"
|
||||
/ spec_name
|
||||
/ file_path
|
||||
)
|
||||
if worktree_path.exists():
|
||||
try:
|
||||
return worktree_path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -6,6 +6,23 @@ You are a focused codebase fit review agent. You have been spawned by the orches
|
||||
|
||||
Ensure new code integrates well with the existing codebase. Check for consistency with project conventions, reuse of existing utilities, and architectural alignment. Focus ONLY on codebase fit - not security, logic correctness, or general quality.
|
||||
|
||||
## CRITICAL: PR Scope and Context
|
||||
|
||||
### What IS in scope (report these issues):
|
||||
1. **Codebase fit issues in changed code** - New code not following project patterns
|
||||
2. **Missed reuse opportunities** - "Existing `utils.ts` has a helper for this"
|
||||
3. **Inconsistent with PR's own changes** - "You used `camelCase` here but `snake_case` elsewhere in the PR"
|
||||
4. **Breaking conventions in touched areas** - "Your change deviates from the pattern in this file"
|
||||
|
||||
### What is NOT in scope (do NOT report):
|
||||
1. **Pre-existing inconsistencies** - Old code that doesn't follow patterns
|
||||
2. **Unrelated suggestions** - Don't suggest patterns for code the PR didn't touch
|
||||
|
||||
**Key distinction:**
|
||||
- ✅ "Your new component doesn't follow the existing pattern in `components/`" - GOOD
|
||||
- ✅ "Consider using existing `formatDate()` helper instead of new implementation" - GOOD
|
||||
- ❌ "The old `legacy/` folder uses different naming conventions" - BAD (pre-existing)
|
||||
|
||||
## Codebase Fit Focus Areas
|
||||
|
||||
### 1. Naming Conventions
|
||||
|
||||
@@ -4,13 +4,32 @@ You are a finding re-investigator. For each unresolved finding from a previous P
|
||||
|
||||
Your job is to prevent false positives from persisting indefinitely by actually reading the code and verifying the issue exists.
|
||||
|
||||
## CRITICAL: Check PR Scope First
|
||||
|
||||
**Before investigating any finding, verify it's within THIS PR's scope:**
|
||||
|
||||
1. **Check if the file is in the PR's changed files list** - If not, likely out-of-scope
|
||||
2. **Check if the line number exists** - If finding cites line 710 but file has 600 lines, it's hallucinated
|
||||
3. **Check for PR references in commit messages** - Commits like `fix: something (#584)` are from OTHER PRs
|
||||
|
||||
**Dismiss findings as `dismissed_false_positive` if:**
|
||||
- The finding references a file NOT in the PR's changed files list AND is not about impact on that file
|
||||
- The line number doesn't exist in the file (hallucinated)
|
||||
- The finding is about code from a merged branch commit (not this PR's work)
|
||||
|
||||
**Keep findings valid if they're about:**
|
||||
- Issues in code the PR actually changed
|
||||
- Impact of PR changes on other code (e.g., "this change breaks callers in X")
|
||||
- Missing updates to related code (e.g., "you updated A but forgot B")
|
||||
|
||||
## Your Mission
|
||||
|
||||
For each finding you receive:
|
||||
1. **READ** the actual code at the file/line location using the Read tool
|
||||
2. **ANALYZE** whether the described issue actually exists in the code
|
||||
3. **PROVIDE** concrete code evidence for your conclusion
|
||||
4. **RETURN** validation status with evidence
|
||||
1. **VERIFY SCOPE** - Is this file/line actually part of this PR?
|
||||
2. **READ** the actual code at the file/line location using the Read tool
|
||||
3. **ANALYZE** whether the described issue actually exists in the code
|
||||
4. **PROVIDE** concrete code evidence for your conclusion
|
||||
5. **RETURN** validation status with evidence
|
||||
|
||||
## Investigation Process
|
||||
|
||||
@@ -122,12 +141,19 @@ Rate your confidence based on how certain you are:
|
||||
|
||||
Watch for these patterns that often indicate false positives:
|
||||
|
||||
1. **Sanitization elsewhere**: Input is validated/sanitized before reaching the flagged code
|
||||
2. **Internal-only code**: Code only handles trusted internal data, not user input
|
||||
3. **Framework protection**: Framework provides automatic protection (e.g., ORM parameterization)
|
||||
4. **Dead code**: The flagged code is never executed in the current codebase
|
||||
5. **Test code**: The issue is in test files where it's acceptable
|
||||
6. **Misread syntax**: Original reviewer misunderstood the language syntax
|
||||
1. **Non-existent line number**: The line number cited doesn't exist or is beyond EOF - hallucinated finding
|
||||
2. **Merged branch code**: Finding is about code from a commit like `fix: something (#584)` - another PR
|
||||
3. **Pre-existing issue, not impact**: Finding flags old bug in untouched code without showing how PR changes relate
|
||||
4. **Sanitization elsewhere**: Input is validated/sanitized before reaching the flagged code
|
||||
5. **Internal-only code**: Code only handles trusted internal data, not user input
|
||||
6. **Framework protection**: Framework provides automatic protection (e.g., ORM parameterization)
|
||||
7. **Dead code**: The flagged code is never executed in the current codebase
|
||||
8. **Test code**: The issue is in test files where it's acceptable
|
||||
9. **Misread syntax**: Original reviewer misunderstood the language syntax
|
||||
|
||||
**Note**: Findings about files outside the PR's changed list are NOT automatically false positives if they're about:
|
||||
- Impact of PR changes on that file (e.g., "your change breaks X")
|
||||
- Missing related updates (e.g., "you forgot to update Y")
|
||||
|
||||
## Common Valid Issue Patterns
|
||||
|
||||
|
||||
@@ -11,6 +11,23 @@ Review the incremental diff for:
|
||||
4. Potential regressions
|
||||
5. Incomplete implementations
|
||||
|
||||
## CRITICAL: PR Scope and Context
|
||||
|
||||
### What IS in scope (report these issues):
|
||||
1. **Issues in changed code** - Problems in files/lines actually modified by this PR
|
||||
2. **Impact on unchanged code** - "This change breaks callers in `other_file.ts`"
|
||||
3. **Missing related changes** - "Similar pattern in `utils.ts` wasn't updated"
|
||||
4. **Incomplete implementations** - "New field added but not handled in serializer"
|
||||
|
||||
### What is NOT in scope (do NOT report):
|
||||
1. **Pre-existing bugs** - Old bugs in code this PR didn't touch
|
||||
2. **Code from merged branches** - Commits with PR references like `(#584)` are from other PRs
|
||||
3. **Unrelated improvements** - Don't suggest refactoring untouched code
|
||||
|
||||
**Key distinction:**
|
||||
- ✅ "Your change breaks the caller in `auth.ts`" - GOOD (impact analysis)
|
||||
- ❌ "The old code in `legacy.ts` has a bug" - BAD (pre-existing, not this PR)
|
||||
|
||||
## Focus Areas
|
||||
|
||||
Since this is a follow-up review, focus on:
|
||||
|
||||
@@ -9,6 +9,40 @@ Perform a focused, efficient follow-up review by:
|
||||
2. Delegating to specialized agents based on what needs verification
|
||||
3. Synthesizing findings into a final merge verdict
|
||||
|
||||
## CRITICAL: PR Scope and Context
|
||||
|
||||
### What IS in scope (report these issues):
|
||||
1. **Issues in changed code** - Problems in files/lines actually modified by this PR
|
||||
2. **Impact on unchanged code** - "You changed X but forgot to update Y that depends on it"
|
||||
3. **Missing related changes** - "This pattern also exists in Z, did you mean to update it too?"
|
||||
4. **Breaking changes** - "This change breaks callers in other files"
|
||||
|
||||
### What is NOT in scope (do NOT report):
|
||||
1. **Pre-existing issues in unchanged code** - If old code has a bug but this PR didn't touch it, don't flag it
|
||||
2. **Code from merged branches** - Commits with PR references like `(#584)` are from OTHER already-reviewed PRs
|
||||
3. **Unrelated improvements** - Don't suggest refactoring code the PR didn't touch
|
||||
|
||||
**Key distinction:**
|
||||
- ✅ "Your change to `validateUser()` breaks the caller in `auth.ts:45`" - GOOD (impact of PR changes)
|
||||
- ✅ "You updated this validation but similar logic in `utils.ts` wasn't updated" - GOOD (incomplete change)
|
||||
- ❌ "The existing code in `legacy.ts` has a SQL injection" - BAD (pre-existing issue, not this PR)
|
||||
- ❌ "This code from commit `fix: something (#584)` has an issue" - BAD (different PR)
|
||||
|
||||
**Why this matters:**
|
||||
When authors merge the base branch into their feature branch, the commit range includes commits from other PRs. The context gathering system filters these out, but if any slip through, recognize them as out-of-scope.
|
||||
|
||||
## Merge Conflicts
|
||||
|
||||
**Check for merge conflicts in the follow-up context.** If `has_merge_conflicts` is `true`:
|
||||
|
||||
1. **Report this prominently** - Merge conflicts block the PR from being merged
|
||||
2. **Add a CRITICAL finding** with category "merge_conflict" and severity "critical"
|
||||
3. **Include in verdict reasoning** - The PR cannot be merged until conflicts are resolved
|
||||
4. **This may be NEW since last review** - Base branch may have changed
|
||||
|
||||
Note: GitHub's API tells us IF there are conflicts but not WHICH files. The finding should state:
|
||||
> "This PR has merge conflicts with the base branch that must be resolved before merging."
|
||||
|
||||
## Available Specialist Agents
|
||||
|
||||
You have access to these specialist agents via the Task tool:
|
||||
|
||||
@@ -10,6 +10,23 @@ For each previous finding, determine whether it has been:
|
||||
- **unresolved**: The issue remains or wasn't addressed
|
||||
- **cant_verify**: Not enough information to determine status
|
||||
|
||||
## CRITICAL: Verify Finding is In-Scope
|
||||
|
||||
**Before verifying any finding, check if it's within THIS PR's scope:**
|
||||
|
||||
1. **Is the file in the PR's changed files list?** - If not AND the finding isn't about impact, mark as `cant_verify`
|
||||
2. **Does the line number exist?** - If finding cites line 710 but file has 600 lines, it was hallucinated
|
||||
3. **Was this from a merged branch?** - Commits with PR references like `(#584)` are from other PRs
|
||||
|
||||
**Mark as `cant_verify` if:**
|
||||
- Finding references a file not in PR AND is not about impact of PR changes on that file
|
||||
- Line number doesn't exist (hallucinated finding)
|
||||
- Finding is about code from another PR's commits
|
||||
|
||||
**Findings can reference files outside the PR if they're about:**
|
||||
- Impact of PR changes (e.g., "change to X breaks caller in Y")
|
||||
- Missing related updates (e.g., "you updated A but forgot B")
|
||||
|
||||
## Verification Process
|
||||
|
||||
For each previous finding:
|
||||
|
||||
@@ -6,6 +6,23 @@ You are a focused logic and correctness review agent. You have been spawned by t
|
||||
|
||||
Verify that the code logic is correct, handles all edge cases, and doesn't introduce subtle bugs. Focus ONLY on logic and correctness issues - not style, security, or general quality.
|
||||
|
||||
## CRITICAL: PR Scope and Context
|
||||
|
||||
### What IS in scope (report these issues):
|
||||
1. **Logic issues in changed code** - Bugs in files/lines modified by this PR
|
||||
2. **Logic impact of changes** - "This change breaks the assumption in `caller.ts:50`"
|
||||
3. **Incomplete state changes** - "You updated state X but forgot to reset Y"
|
||||
4. **Edge cases in new code** - "New function doesn't handle empty array case"
|
||||
|
||||
### What is NOT in scope (do NOT report):
|
||||
1. **Pre-existing bugs** - Old logic issues in untouched code
|
||||
2. **Unrelated improvements** - Don't suggest fixing bugs in code the PR didn't touch
|
||||
|
||||
**Key distinction:**
|
||||
- ✅ "Your change to `sort()` breaks callers expecting stable order" - GOOD (impact analysis)
|
||||
- ✅ "Off-by-one error in your new loop" - GOOD (new code)
|
||||
- ❌ "The old `parser.ts` has a race condition" - BAD (pre-existing, not this PR)
|
||||
|
||||
## Logic Focus Areas
|
||||
|
||||
### 1. Algorithm Correctness
|
||||
|
||||
@@ -6,6 +6,34 @@ You are an expert PR reviewer orchestrating a comprehensive, parallel code revie
|
||||
|
||||
**YOU decide which agents to invoke based on YOUR analysis of the PR.** There are no programmatic rules - you evaluate the PR's content, complexity, and risk areas, then delegate to the appropriate specialists.
|
||||
|
||||
## CRITICAL: PR Scope and Context
|
||||
|
||||
### What IS in scope (report these issues):
|
||||
1. **Issues in changed code** - Problems in files/lines actually modified by this PR
|
||||
2. **Impact on unchanged code** - "You changed X but forgot to update Y that depends on it"
|
||||
3. **Missing related changes** - "This pattern also exists in Z, did you mean to update it too?"
|
||||
4. **Breaking changes** - "This change breaks callers in other files"
|
||||
|
||||
### What is NOT in scope (do NOT report):
|
||||
1. **Pre-existing issues** - Old bugs/issues in code this PR didn't touch
|
||||
2. **Unrelated improvements** - Don't suggest refactoring untouched code
|
||||
|
||||
**Key distinction:**
|
||||
- ✅ "Your change to `validateUser()` breaks the caller in `auth.ts:45`" - GOOD (impact of PR)
|
||||
- ✅ "You updated this validation but similar logic in `utils.ts` wasn't updated" - GOOD (incomplete)
|
||||
- ❌ "The existing code in `legacy.ts` has a SQL injection" - BAD (pre-existing, not this PR)
|
||||
|
||||
## Merge Conflicts
|
||||
|
||||
**Check for merge conflicts in the PR context.** If `has_merge_conflicts` is `true`:
|
||||
|
||||
1. **Report this prominently** - Merge conflicts block the PR from being merged
|
||||
2. **Add a CRITICAL finding** with category "merge_conflict" and severity "critical"
|
||||
3. **Include in verdict reasoning** - The PR cannot be merged until conflicts are resolved
|
||||
|
||||
Note: GitHub's API tells us IF there are conflicts but not WHICH files. The finding should state:
|
||||
> "This PR has merge conflicts with the base branch that must be resolved before merging."
|
||||
|
||||
## Available Specialist Agents
|
||||
|
||||
You have access to these specialized review agents via the Task tool:
|
||||
|
||||
@@ -6,6 +6,23 @@ You are a focused code quality review agent. You have been spawned by the orches
|
||||
|
||||
Perform a thorough code quality review of the provided code changes. Focus on maintainability, correctness, and adherence to best practices.
|
||||
|
||||
## CRITICAL: PR Scope and Context
|
||||
|
||||
### What IS in scope (report these issues):
|
||||
1. **Quality issues in changed code** - Problems in files/lines modified by this PR
|
||||
2. **Quality impact of changes** - "This change increases complexity of `handler.ts`"
|
||||
3. **Incomplete refactoring** - "You cleaned up X but similar pattern in Y wasn't updated"
|
||||
4. **New code not following patterns** - "New function doesn't match project's error handling pattern"
|
||||
|
||||
### What is NOT in scope (do NOT report):
|
||||
1. **Pre-existing quality issues** - Old code smells in untouched code
|
||||
2. **Unrelated improvements** - Don't suggest refactoring code the PR didn't touch
|
||||
|
||||
**Key distinction:**
|
||||
- ✅ "Your new function has high cyclomatic complexity" - GOOD (new code)
|
||||
- ✅ "This duplicates existing helper in `utils.ts`, consider reusing it" - GOOD (guidance)
|
||||
- ❌ "The old `legacy.ts` file has 1000 lines" - BAD (pre-existing, not this PR)
|
||||
|
||||
## Quality Focus Areas
|
||||
|
||||
### 1. Code Complexity
|
||||
|
||||
@@ -6,6 +6,23 @@ You are a focused security review agent. You have been spawned by the orchestrat
|
||||
|
||||
Perform a thorough security review of the provided code changes, focusing ONLY on security vulnerabilities. Do not review code quality, style, or other non-security concerns.
|
||||
|
||||
## CRITICAL: PR Scope and Context
|
||||
|
||||
### What IS in scope (report these issues):
|
||||
1. **Security issues in changed code** - Vulnerabilities introduced or modified by this PR
|
||||
2. **Security impact of changes** - "This change exposes sensitive data to the new endpoint"
|
||||
3. **Missing security for new features** - "New API endpoint lacks authentication"
|
||||
4. **Broken security assumptions** - "Change to auth.ts invalidates security check in handler.ts"
|
||||
|
||||
### What is NOT in scope (do NOT report):
|
||||
1. **Pre-existing vulnerabilities** - Old security issues in code this PR didn't touch
|
||||
2. **Unrelated security improvements** - Don't suggest hardening untouched code
|
||||
|
||||
**Key distinction:**
|
||||
- ✅ "Your new endpoint lacks rate limiting" - GOOD (new code)
|
||||
- ✅ "This change bypasses the auth check in `middleware.ts`" - GOOD (impact analysis)
|
||||
- ❌ "The old `legacy_auth.ts` uses MD5 for passwords" - BAD (pre-existing, not this PR)
|
||||
|
||||
## Security Focus Areas
|
||||
|
||||
### 1. Injection Vulnerabilities
|
||||
|
||||
@@ -204,6 +204,11 @@ class PRContext:
|
||||
# Commit SHAs for worktree creation (PR review isolation)
|
||||
head_sha: str = "" # Commit SHA of PR head (headRefOid)
|
||||
base_sha: str = "" # Commit SHA of PR base (baseRefOid)
|
||||
# Merge conflict status
|
||||
has_merge_conflicts: bool = False # True if PR has conflicts with base branch
|
||||
merge_state_status: str = (
|
||||
"" # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
|
||||
)
|
||||
|
||||
|
||||
class PRContextGatherer:
|
||||
@@ -276,6 +281,17 @@ class PRContextGatherer:
|
||||
# Check if diff was truncated (empty diff but files were changed)
|
||||
diff_truncated = len(diff) == 0 and len(changed_files) > 0
|
||||
|
||||
# Check merge conflict status
|
||||
mergeable = pr_data.get("mergeable", "UNKNOWN")
|
||||
merge_state_status = pr_data.get("mergeStateStatus", "UNKNOWN")
|
||||
has_merge_conflicts = mergeable == "CONFLICTING"
|
||||
|
||||
if has_merge_conflicts:
|
||||
print(
|
||||
f"[Context] ⚠️ PR has merge conflicts (mergeStateStatus: {merge_state_status})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return PRContext(
|
||||
pr_number=self.pr_number,
|
||||
title=pr_data["title"],
|
||||
@@ -296,6 +312,8 @@ class PRContextGatherer:
|
||||
diff_truncated=diff_truncated,
|
||||
head_sha=pr_data.get("headRefOid", ""),
|
||||
base_sha=pr_data.get("baseRefOid", ""),
|
||||
has_merge_conflicts=has_merge_conflicts,
|
||||
merge_state_status=merge_state_status,
|
||||
)
|
||||
|
||||
async def _fetch_pr_metadata(self) -> dict:
|
||||
@@ -317,6 +335,8 @@ class PRContextGatherer:
|
||||
"deletions",
|
||||
"changedFiles",
|
||||
"labels",
|
||||
"mergeable", # MERGEABLE, CONFLICTING, or UNKNOWN
|
||||
"mergeStateStatus", # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1036,28 +1056,56 @@ class FollowupContextGatherer:
|
||||
f"[Followup] Comparing {previous_sha[:8]}...{current_sha[:8]}", flush=True
|
||||
)
|
||||
|
||||
# Get commit comparison
|
||||
# Get PR-scoped files and commits (excludes merge-introduced changes)
|
||||
# This solves the problem where merging develop into a feature branch
|
||||
# would include commits from other PRs in the follow-up review.
|
||||
# Pass reviewed_file_blobs for rebase-resistant comparison
|
||||
reviewed_file_blobs = getattr(self.previous_review, "reviewed_file_blobs", {})
|
||||
try:
|
||||
comparison = await self.gh_client.compare_commits(previous_sha, current_sha)
|
||||
except Exception as e:
|
||||
print(f"[Followup] Error comparing commits: {e}", flush=True)
|
||||
return FollowupReviewContext(
|
||||
pr_number=self.pr_number,
|
||||
previous_review=self.previous_review,
|
||||
previous_commit_sha=previous_sha,
|
||||
current_commit_sha=current_sha,
|
||||
error=f"Failed to compare commits: {e}",
|
||||
pr_files, new_commits = await self.gh_client.get_pr_files_changed_since(
|
||||
self.pr_number, previous_sha, reviewed_file_blobs=reviewed_file_blobs
|
||||
)
|
||||
print(
|
||||
f"[Followup] PR has {len(pr_files)} files, "
|
||||
f"{len(new_commits)} commits since last review"
|
||||
+ (" (blob comparison used)" if reviewed_file_blobs else ""),
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Followup] Error getting PR files/commits: {e}", flush=True)
|
||||
# Fallback to compare_commits if PR endpoints fail
|
||||
print("[Followup] Falling back to commit comparison...", flush=True)
|
||||
try:
|
||||
comparison = await self.gh_client.compare_commits(
|
||||
previous_sha, current_sha
|
||||
)
|
||||
new_commits = comparison.get("commits", [])
|
||||
pr_files = comparison.get("files", [])
|
||||
print(
|
||||
f"[Followup] Fallback: Found {len(new_commits)} commits, "
|
||||
f"{len(pr_files)} files (may include merge-introduced changes)",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e2:
|
||||
print(f"[Followup] Fallback also failed: {e2}", flush=True)
|
||||
return FollowupReviewContext(
|
||||
pr_number=self.pr_number,
|
||||
previous_review=self.previous_review,
|
||||
previous_commit_sha=previous_sha,
|
||||
current_commit_sha=current_sha,
|
||||
error=f"Failed to get PR context: {e}, fallback: {e2}",
|
||||
)
|
||||
|
||||
# Extract data from comparison
|
||||
commits = comparison.get("commits", [])
|
||||
files = comparison.get("files", [])
|
||||
# Use PR files as the canonical list (excludes files from merged branches)
|
||||
commits = new_commits
|
||||
files = pr_files
|
||||
print(
|
||||
f"[Followup] Found {len(commits)} new commits, {len(files)} changed files",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Build diff from file patches
|
||||
# Note: PR files endpoint returns 'filename' key, compare returns 'filename' too
|
||||
diff_parts = []
|
||||
files_changed = []
|
||||
for file_info in files:
|
||||
@@ -1139,6 +1187,26 @@ class FollowupContextGatherer:
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Fetch current merge conflict status
|
||||
has_merge_conflicts = False
|
||||
merge_state_status = "UNKNOWN"
|
||||
try:
|
||||
pr_status = await self.gh_client.pr_get(
|
||||
self.pr_number,
|
||||
json_fields=["mergeable", "mergeStateStatus"],
|
||||
)
|
||||
mergeable = pr_status.get("mergeable", "UNKNOWN")
|
||||
merge_state_status = pr_status.get("mergeStateStatus", "UNKNOWN")
|
||||
has_merge_conflicts = mergeable == "CONFLICTING"
|
||||
|
||||
if has_merge_conflicts:
|
||||
print(
|
||||
f"[Followup] ⚠️ PR has merge conflicts (mergeStateStatus: {merge_state_status})",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Followup] Could not fetch merge status: {e}", flush=True)
|
||||
|
||||
return FollowupReviewContext(
|
||||
pr_number=self.pr_number,
|
||||
previous_review=self.previous_review,
|
||||
@@ -1151,4 +1219,6 @@ class FollowupContextGatherer:
|
||||
+ contributor_reviews,
|
||||
ai_bot_comments_since_review=ai_comments,
|
||||
pr_reviews_since_review=pr_reviews,
|
||||
has_merge_conflicts=has_merge_conflicts,
|
||||
merge_state_status=merge_state_status,
|
||||
)
|
||||
|
||||
@@ -822,14 +822,17 @@ class GHClient:
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- checks: List of check runs with name, status, conclusion
|
||||
- checks: List of check runs with name, state
|
||||
- passing: Number of passing checks
|
||||
- failing: Number of failing checks
|
||||
- pending: Number of pending checks
|
||||
- failed_checks: List of failed check names
|
||||
"""
|
||||
try:
|
||||
args = ["pr", "checks", str(pr_number), "--json", "name,state,conclusion"]
|
||||
# Note: gh pr checks --json only supports: bucket, completedAt, description,
|
||||
# event, link, name, startedAt, state, workflow
|
||||
# The 'state' field directly contains the result (SUCCESS, FAILURE, PENDING, etc.)
|
||||
args = ["pr", "checks", str(pr_number), "--json", "name,state"]
|
||||
args = self._add_repo_flag(args)
|
||||
|
||||
result = await self.run(args, timeout=30.0)
|
||||
@@ -842,15 +845,14 @@ class GHClient:
|
||||
|
||||
for check in checks:
|
||||
state = check.get("state", "").upper()
|
||||
conclusion = check.get("conclusion", "").upper()
|
||||
name = check.get("name", "Unknown")
|
||||
|
||||
if state == "COMPLETED":
|
||||
if conclusion in ("SUCCESS", "NEUTRAL", "SKIPPED"):
|
||||
passing += 1
|
||||
elif conclusion in ("FAILURE", "TIMED_OUT", "CANCELLED"):
|
||||
failing += 1
|
||||
failed_checks.append(name)
|
||||
# gh pr checks 'state' directly contains: SUCCESS, FAILURE, PENDING, NEUTRAL, etc.
|
||||
if state in ("SUCCESS", "NEUTRAL", "SKIPPED"):
|
||||
passing += 1
|
||||
elif state in ("FAILURE", "TIMED_OUT", "CANCELLED", "STARTUP_FAILURE"):
|
||||
failing += 1
|
||||
failed_checks.append(name)
|
||||
else:
|
||||
# PENDING, QUEUED, IN_PROGRESS, etc.
|
||||
pending += 1
|
||||
@@ -872,3 +874,210 @@ class GHClient:
|
||||
"failed_checks": [],
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def get_pr_files(self, pr_number: int) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get files changed by a PR using the PR files endpoint.
|
||||
|
||||
IMPORTANT: This returns only files that are part of the PR's actual changes,
|
||||
NOT files that came in from merging another branch (e.g., develop).
|
||||
This is crucial for follow-up reviews to avoid reviewing code from other PRs.
|
||||
|
||||
Uses: GET /repos/{owner}/{repo}/pulls/{pr_number}/files
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
|
||||
Returns:
|
||||
List of file objects with:
|
||||
- filename: Path to the file
|
||||
- status: added, removed, modified, renamed, copied, changed
|
||||
- additions: Number of lines added
|
||||
- deletions: Number of lines deleted
|
||||
- changes: Total number of line changes
|
||||
- patch: The unified diff patch for this file (may be absent for large files)
|
||||
"""
|
||||
files = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
while True:
|
||||
endpoint = f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/files?page={page}&per_page={per_page}"
|
||||
args = ["api", "--method", "GET", endpoint]
|
||||
|
||||
result = await self.run(args, timeout=60.0)
|
||||
page_files = json.loads(result.stdout) if result.stdout.strip() else []
|
||||
|
||||
if not page_files:
|
||||
break
|
||||
|
||||
files.extend(page_files)
|
||||
|
||||
# Check if we got a full page (more pages might exist)
|
||||
if len(page_files) < per_page:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
# Safety limit to prevent infinite loops
|
||||
if page > 50:
|
||||
logger.warning(
|
||||
f"PR #{pr_number} has more than 5000 files, stopping pagination"
|
||||
)
|
||||
break
|
||||
|
||||
return files
|
||||
|
||||
async def get_pr_commits(self, pr_number: int) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get commits that are part of a PR using the PR commits endpoint.
|
||||
|
||||
IMPORTANT: This returns only commits that are part of the PR's branch,
|
||||
NOT commits that came in from merging another branch (e.g., develop).
|
||||
This is crucial for follow-up reviews to avoid reviewing commits from other PRs.
|
||||
|
||||
Uses: GET /repos/{owner}/{repo}/pulls/{pr_number}/commits
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
|
||||
Returns:
|
||||
List of commit objects with:
|
||||
- sha: Commit SHA
|
||||
- commit: Object with message, author, committer info
|
||||
- author: GitHub user who authored the commit
|
||||
- committer: GitHub user who committed
|
||||
- parents: List of parent commit SHAs
|
||||
"""
|
||||
commits = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
while True:
|
||||
endpoint = f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/commits?page={page}&per_page={per_page}"
|
||||
args = ["api", "--method", "GET", endpoint]
|
||||
|
||||
result = await self.run(args, timeout=60.0)
|
||||
page_commits = json.loads(result.stdout) if result.stdout.strip() else []
|
||||
|
||||
if not page_commits:
|
||||
break
|
||||
|
||||
commits.extend(page_commits)
|
||||
|
||||
# Check if we got a full page (more pages might exist)
|
||||
if len(page_commits) < per_page:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
# Safety limit
|
||||
if page > 10:
|
||||
logger.warning(
|
||||
f"PR #{pr_number} has more than 1000 commits, stopping pagination"
|
||||
)
|
||||
break
|
||||
|
||||
return commits
|
||||
|
||||
async def get_pr_files_changed_since(
|
||||
self,
|
||||
pr_number: int,
|
||||
base_sha: str,
|
||||
reviewed_file_blobs: dict[str, str] | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""
|
||||
Get files and commits that are part of the PR and changed since a specific commit.
|
||||
|
||||
This method solves the "merge introduced commits" problem by:
|
||||
1. Getting the canonical list of PR files (excludes files from merged branches)
|
||||
2. Getting the canonical list of PR commits (excludes commits from merged branches)
|
||||
3. Filtering to only include commits after base_sha
|
||||
|
||||
When a rebase/force-push is detected (base_sha not found in commits), and
|
||||
reviewed_file_blobs is provided, uses blob SHA comparison to identify which
|
||||
files actually changed content. This prevents re-reviewing unchanged files.
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
base_sha: The commit SHA to compare from (e.g., last reviewed commit)
|
||||
reviewed_file_blobs: Optional dict mapping filename -> blob SHA from the
|
||||
previous review. Used as fallback when base_sha is not found (rebase).
|
||||
|
||||
Returns:
|
||||
Tuple of:
|
||||
- List of file objects that are part of the PR (filtered if blob comparison used)
|
||||
- List of commit objects that are part of the PR and after base_sha
|
||||
"""
|
||||
# Get PR's canonical files (these are the actual PR changes)
|
||||
pr_files = await self.get_pr_files(pr_number)
|
||||
|
||||
# Get PR's canonical commits
|
||||
pr_commits = await self.get_pr_commits(pr_number)
|
||||
|
||||
# Find the position of base_sha in PR commits
|
||||
# Use minimum 7-char prefix comparison (git's default short SHA length)
|
||||
base_index = -1
|
||||
min_prefix_len = 7
|
||||
base_prefix = (
|
||||
base_sha[:min_prefix_len] if len(base_sha) >= min_prefix_len else base_sha
|
||||
)
|
||||
for i, commit in enumerate(pr_commits):
|
||||
commit_prefix = commit["sha"][:min_prefix_len]
|
||||
if commit_prefix == base_prefix:
|
||||
base_index = i
|
||||
break
|
||||
|
||||
# Commits after base_sha (these are the new commits to review)
|
||||
if base_index >= 0:
|
||||
new_commits = pr_commits[base_index + 1 :]
|
||||
return pr_files, new_commits
|
||||
|
||||
# base_sha not found in PR commits - this happens when:
|
||||
# 1. The base_sha was from a merge commit (not a direct PR commit)
|
||||
# 2. The PR was rebased/force-pushed
|
||||
logger.warning(
|
||||
f"base_sha {base_sha[:8]} not found in PR #{pr_number} commits. "
|
||||
"PR was likely rebased or force-pushed."
|
||||
)
|
||||
|
||||
# If we have blob SHAs from the previous review, use them to filter files
|
||||
# Blob SHAs persist across rebases - same content = same blob SHA
|
||||
if reviewed_file_blobs: # Only use blob comparison if we have actual blob data
|
||||
changed_files = []
|
||||
unchanged_count = 0
|
||||
for file in pr_files:
|
||||
filename = file.get("filename", "")
|
||||
current_blob_sha = file.get("sha", "")
|
||||
file_status = file.get("status", "")
|
||||
previous_blob_sha = reviewed_file_blobs.get(filename, "")
|
||||
|
||||
# Always include files that were added, removed, or renamed
|
||||
# These are significant changes regardless of blob SHA
|
||||
if file_status in ("added", "removed", "renamed"):
|
||||
changed_files.append(file)
|
||||
elif not previous_blob_sha:
|
||||
# File wasn't in previous review - include it
|
||||
changed_files.append(file)
|
||||
elif current_blob_sha != previous_blob_sha:
|
||||
# File content changed - include it
|
||||
changed_files.append(file)
|
||||
else:
|
||||
# Same blob SHA = same content - skip it
|
||||
unchanged_count += 1
|
||||
|
||||
if unchanged_count > 0:
|
||||
logger.info(
|
||||
f"Blob comparison: {len(changed_files)} files changed, "
|
||||
f"{unchanged_count} unchanged (skipped)"
|
||||
)
|
||||
|
||||
# Return filtered files but all commits (can't filter commits after rebase)
|
||||
return changed_files, pr_commits
|
||||
|
||||
# No blob data available - return all files and commits
|
||||
logger.warning(
|
||||
"No reviewed_file_blobs available for blob comparison. "
|
||||
"Returning all PR files."
|
||||
)
|
||||
return pr_files, pr_commits
|
||||
|
||||
@@ -383,6 +383,9 @@ class PRReviewResult:
|
||||
|
||||
# Follow-up review tracking
|
||||
reviewed_commit_sha: str | None = None # HEAD SHA at time of review
|
||||
reviewed_file_blobs: dict[str, str] = field(
|
||||
default_factory=dict
|
||||
) # filename → blob SHA at time of review (survives rebases)
|
||||
is_followup_review: bool = False # True if this is a follow-up review
|
||||
previous_review_id: int | None = None # Reference to the review this follows up on
|
||||
resolved_findings: list[str] = field(default_factory=list) # Finding IDs now fixed
|
||||
@@ -421,6 +424,7 @@ class PRReviewResult:
|
||||
"quick_scan_summary": self.quick_scan_summary,
|
||||
# Follow-up review fields
|
||||
"reviewed_commit_sha": self.reviewed_commit_sha,
|
||||
"reviewed_file_blobs": self.reviewed_file_blobs,
|
||||
"is_followup_review": self.is_followup_review,
|
||||
"previous_review_id": self.previous_review_id,
|
||||
"resolved_findings": self.resolved_findings,
|
||||
@@ -465,6 +469,7 @@ class PRReviewResult:
|
||||
quick_scan_summary=data.get("quick_scan_summary", {}),
|
||||
# Follow-up review fields
|
||||
reviewed_commit_sha=data.get("reviewed_commit_sha"),
|
||||
reviewed_file_blobs=data.get("reviewed_file_blobs", {}),
|
||||
is_followup_review=data.get("is_followup_review", False),
|
||||
previous_review_id=data.get("previous_review_id"),
|
||||
resolved_findings=data.get("resolved_findings", []),
|
||||
@@ -562,6 +567,12 @@ class FollowupReviewContext:
|
||||
# These are different from comments - they're full review submissions with body text
|
||||
pr_reviews_since_review: list[dict] = field(default_factory=list)
|
||||
|
||||
# Merge conflict status
|
||||
has_merge_conflicts: bool = False # True if PR has conflicts with base branch
|
||||
merge_state_status: str = (
|
||||
"" # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
|
||||
)
|
||||
|
||||
# Error flag - if set, context gathering failed and data may be incomplete
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@@ -435,6 +435,25 @@ class GitHubOrchestrator:
|
||||
# Get HEAD SHA for follow-up review tracking
|
||||
head_sha = self.bot_detector.get_last_commit_sha(pr_context.commits)
|
||||
|
||||
# Get file blob SHAs for rebase-resistant follow-up reviews
|
||||
# Blob SHAs persist across rebases - same content = same blob SHA
|
||||
file_blobs: dict[str, str] = {}
|
||||
try:
|
||||
pr_files = await self.gh_client.get_pr_files(pr_number)
|
||||
for file in pr_files:
|
||||
filename = file.get("filename", "")
|
||||
blob_sha = file.get("sha", "")
|
||||
if filename and blob_sha:
|
||||
file_blobs[filename] = blob_sha
|
||||
print(
|
||||
f"[Review] Captured {len(file_blobs)} file blob SHAs for follow-up tracking",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[Review] Warning: Could not capture file blobs: {e}", flush=True
|
||||
)
|
||||
|
||||
# Create result
|
||||
result = PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
@@ -452,6 +471,8 @@ class GitHubOrchestrator:
|
||||
quick_scan_summary=quick_scan,
|
||||
# Track the commit SHA for follow-up reviews
|
||||
reviewed_commit_sha=head_sha,
|
||||
# Track file blobs for rebase-resistant follow-up reviews
|
||||
reviewed_file_blobs=file_blobs,
|
||||
)
|
||||
|
||||
# Post review if configured
|
||||
|
||||
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
||||
from ..models import FollowupReviewContext, GitHubRunnerConfig
|
||||
|
||||
try:
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
@@ -37,6 +38,7 @@ try:
|
||||
from .prompt_manager import PromptManager
|
||||
from .pydantic_models import FollowupReviewResponse
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
@@ -230,6 +232,27 @@ class FollowupReviewer:
|
||||
"complete", 100, "Follow-up review complete!", context.pr_number
|
||||
)
|
||||
|
||||
# Get file blob SHAs for rebase-resistant follow-up reviews
|
||||
# Blob SHAs persist across rebases - same content = same blob SHA
|
||||
file_blobs: dict[str, str] = {}
|
||||
try:
|
||||
gh_client = GHClient(
|
||||
project_dir=self.project_dir,
|
||||
default_timeout=30.0,
|
||||
repo=self.config.repo,
|
||||
)
|
||||
pr_files = await gh_client.get_pr_files(context.pr_number)
|
||||
for file in pr_files:
|
||||
filename = file.get("filename", "")
|
||||
blob_sha = file.get("sha", "")
|
||||
if filename and blob_sha:
|
||||
file_blobs[filename] = blob_sha
|
||||
logger.info(
|
||||
f"Captured {len(file_blobs)} file blob SHAs for follow-up tracking"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not capture file blobs: {e}")
|
||||
|
||||
return PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
@@ -243,6 +266,7 @@ class FollowupReviewer:
|
||||
reviewed_at=datetime.now().isoformat(),
|
||||
# Follow-up specific fields
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
reviewed_file_blobs=file_blobs,
|
||||
is_followup_review=True,
|
||||
previous_review_id=context.previous_review.review_id,
|
||||
resolved_findings=[f.id for f in resolved],
|
||||
|
||||
@@ -32,6 +32,7 @@ from claude_agent_sdk import AgentDefinition
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
@@ -44,6 +45,7 @@ try:
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from core.client import create_client
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
@@ -498,6 +500,27 @@ The SDK will run invoked agents in parallel automatically.
|
||||
):
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
# Get file blob SHAs for rebase-resistant follow-up reviews
|
||||
# Blob SHAs persist across rebases - same content = same blob SHA
|
||||
file_blobs: dict[str, str] = {}
|
||||
try:
|
||||
gh_client = GHClient(
|
||||
project_dir=self.project_dir,
|
||||
default_timeout=30.0,
|
||||
repo=self.config.repo,
|
||||
)
|
||||
pr_files = await gh_client.get_pr_files(context.pr_number)
|
||||
for file in pr_files:
|
||||
filename = file.get("filename", "")
|
||||
blob_sha = file.get("sha", "")
|
||||
if filename and blob_sha:
|
||||
file_blobs[filename] = blob_sha
|
||||
logger.info(
|
||||
f"Captured {len(file_blobs)} file blob SHAs for follow-up tracking"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not capture file blobs: {e}")
|
||||
|
||||
result = PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
@@ -509,6 +532,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
reviewed_file_blobs=file_blobs,
|
||||
is_followup_review=True,
|
||||
previous_review_id=context.previous_review.review_id
|
||||
or context.previous_review.pr_number,
|
||||
|
||||
@@ -32,6 +32,7 @@ try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget
|
||||
from ..context_gatherer import PRContext, _validate_git_ref
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
@@ -45,6 +46,7 @@ try:
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import PRContext, _validate_git_ref
|
||||
from core.client import create_client
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
@@ -799,6 +801,27 @@ The SDK will run invoked agents in parallel automatically.
|
||||
latest_commit = context.commits[-1]
|
||||
head_sha = latest_commit.get("oid") or latest_commit.get("sha")
|
||||
|
||||
# Get file blob SHAs for rebase-resistant follow-up reviews
|
||||
# Blob SHAs persist across rebases - same content = same blob SHA
|
||||
file_blobs: dict[str, str] = {}
|
||||
try:
|
||||
gh_client = GHClient(
|
||||
project_dir=self.project_dir,
|
||||
default_timeout=30.0,
|
||||
repo=self.config.repo,
|
||||
)
|
||||
pr_files = await gh_client.get_pr_files(context.pr_number)
|
||||
for file in pr_files:
|
||||
filename = file.get("filename", "")
|
||||
blob_sha = file.get("sha", "")
|
||||
if filename and blob_sha:
|
||||
file_blobs[filename] = blob_sha
|
||||
logger.info(
|
||||
f"Captured {len(file_blobs)} file blob SHAs for follow-up tracking"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not capture file blobs: {e}")
|
||||
|
||||
result = PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
@@ -810,6 +833,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
reviewed_commit_sha=head_sha,
|
||||
reviewed_file_blobs=file_blobs,
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
|
||||
@@ -101,6 +101,7 @@ export interface PRReviewResult {
|
||||
error?: string;
|
||||
// Follow-up review fields
|
||||
reviewedCommitSha?: string;
|
||||
reviewedFileBlobs?: Record<string, string>; // filename → blob SHA for rebase-resistant follow-ups
|
||||
isFollowupReview?: boolean;
|
||||
previousReviewId?: number;
|
||||
resolvedFindings?: string[];
|
||||
@@ -542,6 +543,7 @@ function getReviewResult(project: Project, prNumber: number): PRReviewResult | n
|
||||
error: data.error,
|
||||
// Follow-up review fields (snake_case -> camelCase)
|
||||
reviewedCommitSha: data.reviewed_commit_sha,
|
||||
reviewedFileBlobs: data.reviewed_file_blobs,
|
||||
isFollowupReview: data.is_followup_review ?? false,
|
||||
previousReviewId: data.previous_review_id,
|
||||
resolvedFindings: data.resolved_findings ?? [],
|
||||
|
||||
@@ -33,6 +33,7 @@ import { registerDebugHandlers } from './debug-handlers';
|
||||
import { registerClaudeCodeHandlers } from './claude-code-handlers';
|
||||
import { registerMcpHandlers } from './mcp-handlers';
|
||||
import { registerProfileHandlers } from './profile-handlers';
|
||||
import { registerTerminalWorktreeIpcHandlers } from './terminal';
|
||||
import { notificationService } from '../notification-service';
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,9 @@ export function setupIpcHandlers(
|
||||
// Terminal and Claude profile handlers
|
||||
registerTerminalHandlers(terminalManager, getMainWindow);
|
||||
|
||||
// Terminal worktree handlers (isolated development in worktrees)
|
||||
registerTerminalWorktreeIpcHandlers();
|
||||
|
||||
// Agent event handlers (event forwarding from agent manager to renderer)
|
||||
registerAgenteventsHandlers(agentManager, getMainWindow);
|
||||
|
||||
@@ -126,6 +130,7 @@ export {
|
||||
registerProjectHandlers,
|
||||
registerTaskHandlers,
|
||||
registerTerminalHandlers,
|
||||
registerTerminalWorktreeIpcHandlers,
|
||||
registerAgenteventsHandlers,
|
||||
registerSettingsHandlers,
|
||||
registerFileHandlers,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
persistPlanStatusSync,
|
||||
createPlanIfNotExists
|
||||
} from './plan-file-utils';
|
||||
import { findTaskWorktree } from '../../worktree-paths';
|
||||
|
||||
/**
|
||||
* Atomic file write to prevent TOCTOU race conditions.
|
||||
@@ -332,9 +333,9 @@ export function registerTaskExecutionHandlers(
|
||||
);
|
||||
|
||||
// Check if worktree exists - QA needs to run in the worktree where the build happened
|
||||
const worktreePath = path.join(project.path, '.worktrees', task.specId);
|
||||
const worktreeSpecDir = path.join(worktreePath, specsBaseDir, task.specId);
|
||||
const hasWorktree = existsSync(worktreePath);
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
const worktreeSpecDir = worktreePath ? path.join(worktreePath, specsBaseDir, task.specId) : null;
|
||||
const hasWorktree = worktreePath !== null;
|
||||
|
||||
if (approved) {
|
||||
// Write approval to QA report
|
||||
@@ -382,14 +383,14 @@ export function registerTaskExecutionHandlers(
|
||||
}
|
||||
|
||||
// Step 3: Clean untracked files that came from the merge
|
||||
// IMPORTANT: Exclude .auto-claude and .worktrees directories to preserve specs and worktree data
|
||||
const cleanResult = spawnSync('git', ['clean', '-fd', '-e', '.auto-claude', '-e', '.worktrees'], {
|
||||
// IMPORTANT: Exclude .auto-claude directory to preserve specs and worktree data
|
||||
const cleanResult = spawnSync('git', ['clean', '-fd', '-e', '.auto-claude'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
if (cleanResult.status === 0) {
|
||||
console.log('[TASK_REVIEW] Cleaned untracked files in main (excluding .auto-claude and .worktrees)');
|
||||
console.log('[TASK_REVIEW] Cleaned untracked files in main (excluding .auto-claude)');
|
||||
}
|
||||
|
||||
console.log('[TASK_REVIEW] Main branch restored to pre-merge state');
|
||||
@@ -397,7 +398,7 @@ export function registerTaskExecutionHandlers(
|
||||
|
||||
// Write feedback for QA fixer - write to WORKTREE spec dir if it exists
|
||||
// The QA process runs in the worktree where the build and implementation_plan.json are
|
||||
const targetSpecDir = hasWorktree ? worktreeSpecDir : specDir;
|
||||
const targetSpecDir = hasWorktree && worktreeSpecDir ? worktreeSpecDir : specDir;
|
||||
const fixRequestPath = path.join(targetSpecDir, 'QA_FIX_REQUEST.md');
|
||||
|
||||
console.warn('[TASK_REVIEW] Writing QA fix request to:', fixRequestPath);
|
||||
@@ -453,9 +454,9 @@ export function registerTaskExecutionHandlers(
|
||||
// Validate status transition - 'done' can only be set through merge handler
|
||||
// UNLESS there's no worktree (limbo state - already merged/discarded or failed)
|
||||
if (status === 'done') {
|
||||
// Check if worktree exists
|
||||
const worktreePath = path.join(project.path, '.worktrees', taskId);
|
||||
const hasWorktree = existsSync(worktreePath);
|
||||
// Check if worktree exists (task.specId matches worktree folder name)
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
const hasWorktree = worktreePath !== null;
|
||||
|
||||
if (hasWorktree) {
|
||||
// Worktree exists - must use merge workflow
|
||||
|
||||
@@ -12,6 +12,10 @@ import { findTaskAndProject } from './shared';
|
||||
import { parsePythonCommand } from '../../python-detector';
|
||||
import { getToolPath } from '../../cli-tool-manager';
|
||||
import { promisify } from 'util';
|
||||
import {
|
||||
getTaskWorktreeDir,
|
||||
findTaskWorktree,
|
||||
} from '../../worktree-paths';
|
||||
|
||||
/**
|
||||
* Read utility feature settings (for commit message, merge resolver) from settings file
|
||||
@@ -674,12 +678,14 @@ const TERMINAL_DETECTION: Partial<Record<SupportedTerminal, { name: string; path
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape single quotes in a path for use in AppleScript strings
|
||||
* This prevents command injection via malicious directory names
|
||||
* Escape single quotes in a path for safe use in single-quoted shell/script strings.
|
||||
* Works for both AppleScript and shell (bash/sh) contexts.
|
||||
* This prevents command injection via malicious directory names.
|
||||
*/
|
||||
function escapeAppleScriptPath(dirPath: string): string {
|
||||
// In AppleScript, single quotes are escaped by ending the string,
|
||||
// adding an escaped quote, and starting a new string: ' -> '\''
|
||||
function escapeSingleQuotedPath(dirPath: string): string {
|
||||
// Single quotes are escaped by ending the string, adding an escaped quote,
|
||||
// and starting a new string: ' -> '\''
|
||||
// This pattern works in both AppleScript and POSIX shells (bash, sh, zsh)
|
||||
return dirPath.replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
@@ -1069,8 +1075,8 @@ async function openInTerminal(dirPath: string, terminal: SupportedTerminal, cust
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Use open command with the directory
|
||||
// Escape single quotes in dirPath to prevent AppleScript injection
|
||||
const escapedPath = escapeAppleScriptPath(dirPath);
|
||||
// Escape single quotes in dirPath to prevent script injection
|
||||
const escapedPath = escapeSingleQuotedPath(dirPath);
|
||||
|
||||
if (terminal === 'system') {
|
||||
// Use AppleScript to open Terminal.app at the directory
|
||||
@@ -1112,7 +1118,7 @@ async function openInTerminal(dirPath: string, terminal: SupportedTerminal, cust
|
||||
} catch {
|
||||
// xterm doesn't have --working-directory, use -e with a script
|
||||
// Escape the path for shell use within the xterm command
|
||||
const escapedPath = escapeAppleScriptPath(dirPath);
|
||||
const escapedPath = escapeSingleQuotedPath(dirPath);
|
||||
await execFileAsync('xterm', ['-e', `cd '${escapedPath}' && bash`]);
|
||||
}
|
||||
}
|
||||
@@ -1158,7 +1164,7 @@ export function registerWorktreeHandlers(
|
||||
): void {
|
||||
/**
|
||||
* Get the worktree status for a task
|
||||
* Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/
|
||||
* Per-spec architecture: Each spec has its own worktree at .auto-claude/worktrees/tasks/{spec-name}/
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_WORKTREE_STATUS,
|
||||
@@ -1169,10 +1175,10 @@ export function registerWorktreeHandlers(
|
||||
return { success: false, error: 'Task not found' };
|
||||
}
|
||||
|
||||
// Per-spec worktree path: .worktrees/{spec-name}/
|
||||
const worktreePath = path.join(project.path, '.worktrees', task.specId);
|
||||
// Find worktree at .auto-claude/worktrees/tasks/{spec-name}/
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
|
||||
if (!existsSync(worktreePath)) {
|
||||
if (!worktreePath) {
|
||||
return {
|
||||
success: true,
|
||||
data: { exists: false }
|
||||
@@ -1268,7 +1274,7 @@ export function registerWorktreeHandlers(
|
||||
|
||||
/**
|
||||
* Get the diff for a task's worktree
|
||||
* Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/
|
||||
* Per-spec architecture: Each spec has its own worktree at .auto-claude/worktrees/tasks/{spec-name}/
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_WORKTREE_DIFF,
|
||||
@@ -1279,10 +1285,10 @@ export function registerWorktreeHandlers(
|
||||
return { success: false, error: 'Task not found' };
|
||||
}
|
||||
|
||||
// Per-spec worktree path: .worktrees/{spec-name}/
|
||||
const worktreePath = path.join(project.path, '.worktrees', task.specId);
|
||||
// Find worktree at .auto-claude/worktrees/tasks/{spec-name}/
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
|
||||
if (!existsSync(worktreePath)) {
|
||||
if (!worktreePath) {
|
||||
return { success: false, error: 'No worktree found for this task' };
|
||||
}
|
||||
|
||||
@@ -1415,8 +1421,8 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
|
||||
// Check worktree exists before merge
|
||||
const worktreePath = path.join(project.path, '.worktrees', task.specId);
|
||||
debug('Worktree path:', worktreePath, 'exists:', existsSync(worktreePath));
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
debug('Worktree path:', worktreePath, 'exists:', !!worktreePath);
|
||||
|
||||
// Check if changes are already staged (for stage-only mode)
|
||||
if (options?.noCommit) {
|
||||
@@ -1662,7 +1668,7 @@ export function registerWorktreeHandlers(
|
||||
// This is the same cleanup as the full merge path, needed because
|
||||
// stageOnly defaults to true for human_review tasks
|
||||
try {
|
||||
if (existsSync(worktreePath)) {
|
||||
if (worktreePath && existsSync(worktreePath)) {
|
||||
execFileSync(getToolPath('git'), ['worktree', 'remove', '--force', worktreePath], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
@@ -1708,7 +1714,7 @@ export function registerWorktreeHandlers(
|
||||
// Clean up worktree after successful full merge (fixes #243)
|
||||
// This allows drag-to-Done workflow since TASK_UPDATE_STATUS blocks 'done' when worktree exists
|
||||
try {
|
||||
if (existsSync(worktreePath)) {
|
||||
if (worktreePath && existsSync(worktreePath)) {
|
||||
execFileSync(getToolPath('git'), ['worktree', 'remove', '--force', worktreePath], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
@@ -1756,11 +1762,14 @@ export function registerWorktreeHandlers(
|
||||
// because ProjectStore prefers the worktree version when deduplicating tasks.
|
||||
// OPTIMIZATION: Use async I/O and parallel updates to prevent UI blocking
|
||||
// NOTE: The worktree has the same directory structure as main project
|
||||
const worktreeSpecDir = path.join(worktreePath, project.autoBuildPath || '.auto-claude', 'specs', task.specId);
|
||||
const planPaths = [
|
||||
const planPaths: { path: string; isMain: boolean }[] = [
|
||||
{ path: path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), isMain: true },
|
||||
{ path: path.join(worktreeSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), isMain: false }
|
||||
];
|
||||
// Add worktree plan path if worktree exists
|
||||
if (worktreePath) {
|
||||
const worktreeSpecDir = path.join(worktreePath, project.autoBuildPath || '.auto-claude', 'specs', task.specId);
|
||||
planPaths.push({ path: path.join(worktreeSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), isMain: false });
|
||||
}
|
||||
|
||||
const { promises: fsPromises } = require('fs');
|
||||
|
||||
@@ -2075,7 +2084,7 @@ export function registerWorktreeHandlers(
|
||||
|
||||
/**
|
||||
* Discard the worktree changes
|
||||
* Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/
|
||||
* Per-spec architecture: Each spec has its own worktree at .auto-claude/worktrees/tasks/{spec-name}/
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_WORKTREE_DISCARD,
|
||||
@@ -2086,10 +2095,10 @@ export function registerWorktreeHandlers(
|
||||
return { success: false, error: 'Task not found' };
|
||||
}
|
||||
|
||||
// Per-spec worktree path: .worktrees/{spec-name}/
|
||||
const worktreePath = path.join(project.path, '.worktrees', task.specId);
|
||||
// Find worktree at .auto-claude/worktrees/tasks/{spec-name}/
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
|
||||
if (!existsSync(worktreePath)) {
|
||||
if (!worktreePath) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -2153,7 +2162,7 @@ export function registerWorktreeHandlers(
|
||||
|
||||
/**
|
||||
* List all spec worktrees for a project
|
||||
* Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/
|
||||
* Per-spec architecture: Each spec has its own worktree at .auto-claude/worktrees/tasks/{spec-name}/
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_LIST_WORKTREES,
|
||||
@@ -2164,23 +2173,11 @@ export function registerWorktreeHandlers(
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const worktreesDir = path.join(project.path, '.worktrees');
|
||||
const worktrees: WorktreeListItem[] = [];
|
||||
const worktreesDir = getTaskWorktreeDir(project.path);
|
||||
|
||||
if (!existsSync(worktreesDir)) {
|
||||
return { success: true, data: { worktrees } };
|
||||
}
|
||||
|
||||
// Get all directories in .worktrees
|
||||
const entries = readdirSync(worktreesDir);
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(worktreesDir, entry);
|
||||
const stat = statSync(entryPath);
|
||||
|
||||
// Skip worker directories and non-directories
|
||||
if (!stat.isDirectory() || entry.startsWith('worker-')) {
|
||||
continue;
|
||||
}
|
||||
// Helper to process a single worktree entry
|
||||
const processWorktreeEntry = (entry: string, entryPath: string) => {
|
||||
|
||||
try {
|
||||
// Get branch info
|
||||
@@ -2251,6 +2248,22 @@ export function registerWorktreeHandlers(
|
||||
console.error(`Error getting info for worktree ${entry}:`, gitError);
|
||||
// Skip this worktree if we can't get git info
|
||||
}
|
||||
};
|
||||
|
||||
// Scan worktrees directory
|
||||
if (existsSync(worktreesDir)) {
|
||||
const entries = readdirSync(worktreesDir);
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(worktreesDir, entry);
|
||||
try {
|
||||
const stat = statSync(entryPath);
|
||||
if (stat.isDirectory()) {
|
||||
processWorktreeEntry(entry, entryPath);
|
||||
}
|
||||
} catch {
|
||||
// Skip entries that can't be stat'd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: { worktrees } };
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Terminal handlers module
|
||||
*
|
||||
* This module organizes terminal worktree-related IPC handlers:
|
||||
* - Worktree operations (create, list, remove)
|
||||
*/
|
||||
|
||||
import { registerTerminalWorktreeHandlers } from './worktree-handlers';
|
||||
|
||||
/**
|
||||
* Register all terminal worktree IPC handlers
|
||||
*/
|
||||
export function registerTerminalWorktreeIpcHandlers(): void {
|
||||
registerTerminalWorktreeHandlers();
|
||||
}
|
||||
|
||||
export { registerTerminalWorktreeHandlers } from './worktree-handlers';
|
||||
@@ -0,0 +1,373 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type {
|
||||
IPCResult,
|
||||
CreateTerminalWorktreeRequest,
|
||||
TerminalWorktreeConfig,
|
||||
TerminalWorktreeResult,
|
||||
} from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { debugLog, debugError } from '../../../shared/utils/debug-logger';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { parseEnvFile } from '../utils';
|
||||
import {
|
||||
getTerminalWorktreeDir,
|
||||
getTerminalWorktreePath,
|
||||
} from '../../worktree-paths';
|
||||
|
||||
// Shared validation regex for worktree names - lowercase alphanumeric with dashes/underscores
|
||||
// Must start and end with alphanumeric character
|
||||
const WORKTREE_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/;
|
||||
|
||||
// Validation regex for git branch names - allows alphanumeric, dots, slashes, dashes, underscores
|
||||
const GIT_BRANCH_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/;
|
||||
|
||||
/**
|
||||
* Validate that projectPath is a registered project
|
||||
*/
|
||||
function isValidProjectPath(projectPath: string): boolean {
|
||||
const projects = projectStore.getProjects();
|
||||
return projects.some(p => p.path === projectPath);
|
||||
}
|
||||
|
||||
const MAX_TERMINAL_WORKTREES = 12;
|
||||
|
||||
/**
|
||||
* Get the default branch from project settings OR env config
|
||||
*/
|
||||
function getDefaultBranch(projectPath: string): string {
|
||||
const project = projectStore.getProjects().find(p => p.path === projectPath);
|
||||
if (project?.settings?.mainBranch) {
|
||||
debugLog('[TerminalWorktree] Using mainBranch from project settings:', project.settings.mainBranch);
|
||||
return project.settings.mainBranch;
|
||||
}
|
||||
|
||||
const envPath = path.join(projectPath, '.auto-claude', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
try {
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
const vars = parseEnvFile(content);
|
||||
if (vars['DEFAULT_BRANCH']) {
|
||||
debugLog('[TerminalWorktree] Using DEFAULT_BRANCH from env config:', vars['DEFAULT_BRANCH']);
|
||||
return vars['DEFAULT_BRANCH'];
|
||||
}
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Error reading env file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const branch of ['main', 'master']) {
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', branch], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
debugLog('[TerminalWorktree] Auto-detected branch:', branch);
|
||||
return branch;
|
||||
} catch {
|
||||
// Branch doesn't exist, try next
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to current branch - wrap in try-catch
|
||||
try {
|
||||
const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
debugLog('[TerminalWorktree] Falling back to current branch:', currentBranch);
|
||||
return currentBranch;
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Error detecting current branch:', error);
|
||||
return 'main'; // Safe default
|
||||
}
|
||||
}
|
||||
|
||||
function saveWorktreeConfig(worktreePath: string, config: TerminalWorktreeConfig): void {
|
||||
writeFileSync(path.join(worktreePath, 'config.json'), JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
function loadWorktreeConfig(worktreePath: string): TerminalWorktreeConfig | null {
|
||||
const configPath = path.join(worktreePath, 'config.json');
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Corrupted config.json in:', configPath, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createTerminalWorktree(
|
||||
request: CreateTerminalWorktreeRequest
|
||||
): Promise<TerminalWorktreeResult> {
|
||||
const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch } = request;
|
||||
|
||||
debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch });
|
||||
|
||||
// Validate projectPath against registered projects
|
||||
if (!isValidProjectPath(projectPath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid project path',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate worktree name - use shared regex (lowercase only)
|
||||
if (!WORKTREE_NAME_REGEX.test(name)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid worktree name. Use lowercase letters, numbers, dashes, and underscores. Must start and end with alphanumeric.',
|
||||
};
|
||||
}
|
||||
|
||||
// CRITICAL: Validate customBaseBranch to prevent command injection
|
||||
if (customBaseBranch && !GIT_BRANCH_REGEX.test(customBaseBranch)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid base branch name',
|
||||
};
|
||||
}
|
||||
|
||||
const existing = await listTerminalWorktrees(projectPath);
|
||||
if (existing.length >= MAX_TERMINAL_WORKTREES) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Maximum of ${MAX_TERMINAL_WORKTREES} terminal worktrees reached.`,
|
||||
};
|
||||
}
|
||||
|
||||
const worktreePath = getTerminalWorktreePath(projectPath, name);
|
||||
const branchName = `terminal/${name}`;
|
||||
let directoryCreated = false;
|
||||
|
||||
try {
|
||||
if (existsSync(worktreePath)) {
|
||||
return { success: false, error: `Worktree '${name}' already exists.` };
|
||||
}
|
||||
|
||||
mkdirSync(getTerminalWorktreeDir(projectPath), { recursive: true });
|
||||
directoryCreated = true;
|
||||
|
||||
// Use custom base branch if provided, otherwise detect default
|
||||
const baseBranch = customBaseBranch || getDefaultBranch(projectPath);
|
||||
debugLog('[TerminalWorktree] Using base branch:', baseBranch, customBaseBranch ? '(custom)' : '(default)');
|
||||
|
||||
try {
|
||||
execFileSync('git', ['fetch', 'origin', baseBranch], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
debugLog('[TerminalWorktree] Fetched latest from origin/' + baseBranch);
|
||||
} catch {
|
||||
debugLog('[TerminalWorktree] Could not fetch from remote, continuing with local branch');
|
||||
}
|
||||
|
||||
let baseRef = baseBranch;
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', `origin/${baseBranch}`], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
baseRef = `origin/${baseBranch}`;
|
||||
debugLog('[TerminalWorktree] Using remote ref:', baseRef);
|
||||
} catch {
|
||||
debugLog('[TerminalWorktree] Remote ref not found, using local branch:', baseBranch);
|
||||
}
|
||||
|
||||
if (createGitBranch) {
|
||||
execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
debugLog('[TerminalWorktree] Created worktree with branch:', branchName, 'from', baseRef);
|
||||
} else {
|
||||
execFileSync('git', ['worktree', 'add', '--detach', worktreePath, baseRef], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
debugLog('[TerminalWorktree] Created worktree in detached HEAD mode from', baseRef);
|
||||
}
|
||||
|
||||
const config: TerminalWorktreeConfig = {
|
||||
name,
|
||||
worktreePath,
|
||||
branchName: createGitBranch ? branchName : '',
|
||||
baseBranch,
|
||||
hasGitBranch: createGitBranch,
|
||||
taskId,
|
||||
createdAt: new Date().toISOString(),
|
||||
terminalId,
|
||||
};
|
||||
|
||||
saveWorktreeConfig(worktreePath, config);
|
||||
debugLog('[TerminalWorktree] Saved config for worktree:', name);
|
||||
|
||||
return { success: true, config };
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Error creating worktree:', error);
|
||||
|
||||
// Cleanup: remove the worktree directory if git worktree creation failed
|
||||
if (directoryCreated && existsSync(worktreePath)) {
|
||||
try {
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
debugLog('[TerminalWorktree] Cleaned up failed worktree directory:', worktreePath);
|
||||
// Also prune stale worktree registrations in case git worktree add partially succeeded
|
||||
try {
|
||||
execFileSync('git', ['worktree', 'prune'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
debugLog('[TerminalWorktree] Pruned stale worktree registrations');
|
||||
} catch {
|
||||
// Ignore prune errors - not critical
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
debugError('[TerminalWorktree] Failed to cleanup worktree directory:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create worktree',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function listTerminalWorktrees(projectPath: string): Promise<TerminalWorktreeConfig[]> {
|
||||
// Validate projectPath against registered projects
|
||||
if (!isValidProjectPath(projectPath)) {
|
||||
debugError('[TerminalWorktree] Invalid project path for listing:', projectPath);
|
||||
return [];
|
||||
}
|
||||
|
||||
const configs: TerminalWorktreeConfig[] = [];
|
||||
const worktreeDir = getTerminalWorktreeDir(projectPath);
|
||||
|
||||
if (existsSync(worktreeDir)) {
|
||||
try {
|
||||
for (const dir of readdirSync(worktreeDir, { withFileTypes: true })) {
|
||||
if (dir.isDirectory()) {
|
||||
const worktreePath = path.join(worktreeDir, dir.name);
|
||||
const config = loadWorktreeConfig(worktreePath);
|
||||
if (config) {
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Error listing worktrees:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
async function removeTerminalWorktree(
|
||||
projectPath: string,
|
||||
name: string,
|
||||
deleteBranch: boolean = false
|
||||
): Promise<IPCResult> {
|
||||
debugLog('[TerminalWorktree] Removing worktree:', { name, deleteBranch, projectPath });
|
||||
|
||||
// Validate projectPath against registered projects
|
||||
if (!isValidProjectPath(projectPath)) {
|
||||
return { success: false, error: 'Invalid project path' };
|
||||
}
|
||||
|
||||
// Validate worktree name to prevent path traversal
|
||||
if (!WORKTREE_NAME_REGEX.test(name)) {
|
||||
return { success: false, error: 'Invalid worktree name' };
|
||||
}
|
||||
|
||||
const worktreePath = getTerminalWorktreePath(projectPath, name);
|
||||
const config = loadWorktreeConfig(worktreePath);
|
||||
|
||||
if (!config) {
|
||||
return { success: false, error: 'Worktree not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
if (existsSync(worktreePath)) {
|
||||
execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
debugLog('[TerminalWorktree] Removed git worktree');
|
||||
}
|
||||
|
||||
if (deleteBranch && config.hasGitBranch && config.branchName) {
|
||||
// Re-validate branch name from config file (defense in depth - config could be modified)
|
||||
if (!GIT_BRANCH_REGEX.test(config.branchName)) {
|
||||
debugError('[TerminalWorktree] Invalid branch name in config:', config.branchName);
|
||||
} else {
|
||||
try {
|
||||
execFileSync('git', ['branch', '-D', config.branchName], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
debugLog('[TerminalWorktree] Deleted branch:', config.branchName);
|
||||
} catch {
|
||||
debugLog('[TerminalWorktree] Branch not found or already deleted:', config.branchName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Error removing worktree:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove worktree',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTerminalWorktreeHandlers(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_WORKTREE_CREATE,
|
||||
async (_, request: CreateTerminalWorktreeRequest): Promise<TerminalWorktreeResult> => {
|
||||
return createTerminalWorktree(request);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_WORKTREE_LIST,
|
||||
async (_, projectPath: string): Promise<IPCResult<TerminalWorktreeConfig[]>> => {
|
||||
try {
|
||||
const configs = await listTerminalWorktrees(projectPath);
|
||||
return { success: true, data: configs };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list worktrees',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_WORKTREE_REMOVE,
|
||||
async (
|
||||
_,
|
||||
projectPath: string,
|
||||
name: string,
|
||||
deleteBranch: boolean
|
||||
): Promise<IPCResult> => {
|
||||
return removeTerminalWorktree(projectPath, name, deleteBranch);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask } from '../shared/types';
|
||||
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir } from '../shared/constants';
|
||||
import { getAutoBuildPath, isInitialized } from './project-initializer';
|
||||
import { getTaskWorktreeDir } from './worktree-paths';
|
||||
|
||||
interface TabState {
|
||||
openProjectIds: string[];
|
||||
@@ -263,8 +264,7 @@ export class ProjectStore {
|
||||
// 2. Scan worktree specs directories
|
||||
// NOTE FOR MAINTAINERS: Worktree tasks are only included if the spec also exists in main.
|
||||
// This prevents deleted tasks from "coming back" when the worktree isn't cleaned up.
|
||||
// Alternative behavior: include all worktree tasks (remove the mainSpecIds check below).
|
||||
const worktreesDir = path.join(project.path, '.worktrees');
|
||||
const worktreesDir = getTaskWorktreeDir(project.path);
|
||||
if (existsSync(worktreesDir)) {
|
||||
try {
|
||||
const worktrees = readdirSync(worktreesDir, { withFileTypes: true });
|
||||
@@ -643,7 +643,7 @@ export class ProjectStore {
|
||||
}
|
||||
|
||||
// 2. Check worktrees
|
||||
const worktreesDir = path.join(projectPath, '.worktrees');
|
||||
const worktreesDir = getTaskWorktreeDir(projectPath);
|
||||
if (existsSync(worktreesDir)) {
|
||||
try {
|
||||
const worktrees = readdirSync(worktreesDir, { withFileTypes: true });
|
||||
|
||||
@@ -344,16 +344,12 @@ export class ReleaseService extends EventEmitter {
|
||||
tasks: Task[]
|
||||
): Promise<UnmergedWorktreeInfo[]> {
|
||||
const unmerged: UnmergedWorktreeInfo[] = [];
|
||||
|
||||
// Get worktrees directory
|
||||
const worktreesDir = path.join(projectPath, '.worktrees', 'auto-claude');
|
||||
const worktreesDir = path.join(projectPath, '.auto-claude', 'worktrees', 'tasks');
|
||||
|
||||
if (!existsSync(worktreesDir)) {
|
||||
// No worktrees exist at all - all clear
|
||||
return [];
|
||||
}
|
||||
|
||||
// List all spec worktrees
|
||||
let worktreeFolders: string[];
|
||||
try {
|
||||
worktreeFolders = readdirSync(worktreesDir, { withFileTypes: true })
|
||||
@@ -366,17 +362,16 @@ export class ReleaseService extends EventEmitter {
|
||||
// Check each spec ID that's in this release
|
||||
for (const specId of releaseSpecIds) {
|
||||
// Find the worktree folder for this spec
|
||||
// Spec IDs are like "001-feature-name", worktree folders match
|
||||
const worktreeFolder = worktreeFolders.find(folder =>
|
||||
const matchingFolder = worktreeFolders.find(folder =>
|
||||
folder === specId || folder.startsWith(`${specId}-`)
|
||||
);
|
||||
|
||||
if (!worktreeFolder) {
|
||||
if (!matchingFolder) {
|
||||
// No worktree for this spec - it's already merged/cleaned up
|
||||
continue;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(worktreesDir, worktreeFolder);
|
||||
const worktreePath = path.join(worktreesDir, matchingFolder);
|
||||
|
||||
// Get the task info for better error messages
|
||||
const task = tasks.find(t => t.specId === specId);
|
||||
|
||||
@@ -2,6 +2,15 @@ import path from 'path';
|
||||
import { existsSync, readFileSync, watchFile } from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { TaskLogs, TaskLogPhase, TaskLogStreamChunk, TaskPhaseLog } from '../shared/types';
|
||||
import { findTaskWorktree } from './worktree-paths';
|
||||
|
||||
function findWorktreeSpecDir(projectPath: string, specId: string, specsRelPath: string): string | null {
|
||||
const worktreePath = findTaskWorktree(projectPath, specId);
|
||||
if (worktreePath) {
|
||||
return path.join(worktreePath, specsRelPath, specId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for loading and watching phase-based task logs (task_logs.json)
|
||||
@@ -120,7 +129,7 @@ export class TaskLogService extends EventEmitter {
|
||||
worktreeSpecDir = watchedInfo[1].worktreeSpecDir;
|
||||
} else if (projectPath && specsRelPath && specId) {
|
||||
// Calculate worktree path from provided params
|
||||
worktreeSpecDir = path.join(projectPath, '.worktrees', specId, specsRelPath, specId);
|
||||
worktreeSpecDir = findWorktreeSpecDir(projectPath, specId, specsRelPath);
|
||||
}
|
||||
|
||||
if (!worktreeSpecDir) {
|
||||
@@ -178,10 +187,9 @@ export class TaskLogService extends EventEmitter {
|
||||
const mainLogFile = path.join(specDir, 'task_logs.json');
|
||||
|
||||
// Calculate worktree spec directory path if we have project info
|
||||
// Worktree structure: .worktrees/{specId}/{specsRelPath}/{specId}/
|
||||
let worktreeSpecDir: string | null = null;
|
||||
if (projectPath && specsRelPath) {
|
||||
worktreeSpecDir = path.join(projectPath, '.worktrees', specId, specsRelPath, specId);
|
||||
worktreeSpecDir = findWorktreeSpecDir(projectPath, specId, specsRelPath);
|
||||
}
|
||||
|
||||
// Store watched paths for this specId
|
||||
|
||||
@@ -154,7 +154,7 @@ export class SpecNumberLock {
|
||||
maxNumber = Math.max(maxNumber, this.scanSpecsDir(mainSpecsDir));
|
||||
|
||||
// 2. Scan all worktree specs
|
||||
const worktreesDir = path.join(this.projectDir, '.worktrees');
|
||||
const worktreesDir = path.join(this.projectDir, '.auto-claude', 'worktrees', 'tasks');
|
||||
if (existsSync(worktreesDir)) {
|
||||
try {
|
||||
const worktrees = readdirSync(worktreesDir, { withFileTypes: true });
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Shared worktree path utilities
|
||||
*
|
||||
* Centralizes all worktree path constants and helper functions to avoid duplication
|
||||
* and ensure consistent path handling across the application.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
// Path constants for worktree directories
|
||||
export const TASK_WORKTREE_DIR = '.auto-claude/worktrees/tasks';
|
||||
export const TERMINAL_WORKTREE_DIR = '.auto-claude/worktrees/terminal';
|
||||
|
||||
// Legacy path for backwards compatibility
|
||||
export const LEGACY_WORKTREE_DIR = '.worktrees';
|
||||
|
||||
/**
|
||||
* Get the task worktrees directory path
|
||||
*/
|
||||
export function getTaskWorktreeDir(projectPath: string): string {
|
||||
return path.join(projectPath, TASK_WORKTREE_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path for a specific task worktree
|
||||
*/
|
||||
export function getTaskWorktreePath(projectPath: string, specId: string): string {
|
||||
return path.join(projectPath, TASK_WORKTREE_DIR, specId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a task worktree path, checking new location first then legacy
|
||||
* Returns the path if found, null otherwise
|
||||
*/
|
||||
export function findTaskWorktree(projectPath: string, specId: string): string | null {
|
||||
// Check new path first
|
||||
const newPath = path.join(projectPath, TASK_WORKTREE_DIR, specId);
|
||||
if (existsSync(newPath)) return newPath;
|
||||
|
||||
// Legacy fallback
|
||||
const legacyPath = path.join(projectPath, LEGACY_WORKTREE_DIR, specId);
|
||||
if (existsSync(legacyPath)) return legacyPath;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the terminal worktrees directory path
|
||||
*/
|
||||
export function getTerminalWorktreeDir(projectPath: string): string {
|
||||
return path.join(projectPath, TERMINAL_WORKTREE_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path for a specific terminal worktree
|
||||
*/
|
||||
export function getTerminalWorktreePath(projectPath: string, name: string): string {
|
||||
return path.join(projectPath, TERMINAL_WORKTREE_DIR, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a terminal worktree path, checking new location first then legacy
|
||||
* Returns the path if found, null otherwise
|
||||
*/
|
||||
export function findTerminalWorktree(projectPath: string, name: string): string | null {
|
||||
// Check new path first
|
||||
const newPath = path.join(projectPath, TERMINAL_WORKTREE_DIR, name);
|
||||
if (existsSync(newPath)) return newPath;
|
||||
|
||||
// Legacy fallback (terminal worktrees used terminal-{name} prefix)
|
||||
const legacyPath = path.join(projectPath, LEGACY_WORKTREE_DIR, `terminal-${name}`);
|
||||
if (existsSync(legacyPath)) return legacyPath;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -320,6 +320,7 @@ export interface PRReviewResult {
|
||||
error?: string;
|
||||
// Follow-up review fields
|
||||
reviewedCommitSha?: string;
|
||||
reviewedFileBlobs?: Record<string, string>; // filename → blob SHA for rebase-resistant follow-ups
|
||||
isFollowupReview?: boolean;
|
||||
previousReviewId?: number;
|
||||
resolvedFindings?: string[];
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
RateLimitInfo,
|
||||
ClaudeProfile,
|
||||
ClaudeProfileSettings,
|
||||
ClaudeUsageSnapshot
|
||||
ClaudeUsageSnapshot,
|
||||
CreateTerminalWorktreeRequest,
|
||||
TerminalWorktreeConfig,
|
||||
TerminalWorktreeResult,
|
||||
} from '../../shared/types';
|
||||
|
||||
/** Type for proactive swap notification events */
|
||||
@@ -48,6 +51,11 @@ export interface TerminalAPI {
|
||||
) => Promise<IPCResult<import('../../shared/types').SessionDateRestoreResult>>;
|
||||
checkTerminalPtyAlive: (terminalId: string) => Promise<IPCResult<{ alive: boolean }>>;
|
||||
|
||||
// Terminal Worktree Operations (isolated development)
|
||||
createTerminalWorktree: (request: CreateTerminalWorktreeRequest) => Promise<TerminalWorktreeResult>;
|
||||
listTerminalWorktrees: (projectPath: string) => Promise<IPCResult<TerminalWorktreeConfig[]>>;
|
||||
removeTerminalWorktree: (projectPath: string, name: string, deleteBranch?: boolean) => Promise<IPCResult>;
|
||||
|
||||
// Terminal Event Listeners
|
||||
onTerminalOutput: (callback: (id: string, data: string) => void) => () => void;
|
||||
onTerminalExit: (callback: (id: string, exitCode: number) => void) => () => void;
|
||||
@@ -137,6 +145,16 @@ export const createTerminalAPI = (): TerminalAPI => ({
|
||||
checkTerminalPtyAlive: (terminalId: string): Promise<IPCResult<{ alive: boolean }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CHECK_PTY_ALIVE, terminalId),
|
||||
|
||||
// Terminal Worktree Operations (isolated development)
|
||||
createTerminalWorktree: (request: CreateTerminalWorktreeRequest): Promise<TerminalWorktreeResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_CREATE, request),
|
||||
|
||||
listTerminalWorktrees: (projectPath: string): Promise<IPCResult<TerminalWorktreeConfig[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_LIST, projectPath),
|
||||
|
||||
removeTerminalWorktree: (projectPath: string, name: string, deleteBranch: boolean = false): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_REMOVE, projectPath, name, deleteBranch),
|
||||
|
||||
// Terminal Event Listeners
|
||||
onTerminalOutput: (
|
||||
callback: (id: string, data: string) => void
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { FileDown } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useTerminalStore } from '../stores/terminal-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
import type { TerminalProps } from './terminal/types';
|
||||
import type { TerminalWorktreeConfig } from '../../shared/types';
|
||||
import { TerminalHeader } from './terminal/TerminalHeader';
|
||||
import { CreateWorktreeDialog } from './terminal/CreateWorktreeDialog';
|
||||
import { useXterm } from './terminal/useXterm';
|
||||
import { usePtyProcess } from './terminal/usePtyProcess';
|
||||
import { useTerminalEvents } from './terminal/useTerminalEvents';
|
||||
@@ -25,10 +29,24 @@ export function Terminal({
|
||||
const isMountedRef = useRef(true);
|
||||
const isCreatedRef = useRef(false);
|
||||
|
||||
// Worktree dialog state
|
||||
const [showWorktreeDialog, setShowWorktreeDialog] = useState(false);
|
||||
|
||||
// Terminal store
|
||||
const terminal = useTerminalStore((state) => state.terminals.find((t) => t.id === id));
|
||||
const setClaudeMode = useTerminalStore((state) => state.setClaudeMode);
|
||||
const updateTerminal = useTerminalStore((state) => state.updateTerminal);
|
||||
const setAssociatedTask = useTerminalStore((state) => state.setAssociatedTask);
|
||||
const setWorktreeConfig = useTerminalStore((state) => state.setWorktreeConfig);
|
||||
|
||||
// Use cwd from store if available (for worktree), otherwise use prop
|
||||
const effectiveCwd = terminal?.cwd || cwd;
|
||||
|
||||
// Settings store for IDE preferences
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
// Toast for user feedback
|
||||
const { toast } = useToast();
|
||||
|
||||
const associatedTask = terminal?.associatedTaskId
|
||||
? tasks.find((t) => t.id === terminal.associatedTaskId)
|
||||
@@ -43,7 +61,7 @@ export function Terminal({
|
||||
// Auto-naming functionality
|
||||
const { handleCommandEnter, cleanup: cleanupAutoNaming } = useAutoNaming({
|
||||
terminalId: id,
|
||||
cwd,
|
||||
cwd: effectiveCwd,
|
||||
});
|
||||
|
||||
// Initialize xterm with command tracking
|
||||
@@ -67,9 +85,9 @@ export function Terminal({
|
||||
});
|
||||
|
||||
// Create PTY process
|
||||
usePtyProcess({
|
||||
const { prepareForRecreate, resetForRecreate } = usePtyProcess({
|
||||
terminalId: id,
|
||||
cwd,
|
||||
cwd: effectiveCwd,
|
||||
projectPath,
|
||||
cols,
|
||||
rows,
|
||||
@@ -119,8 +137,8 @@ export function Terminal({
|
||||
|
||||
const handleInvokeClaude = useCallback(() => {
|
||||
setClaudeMode(id, true);
|
||||
window.electronAPI.invokeClaudeInTerminal(id, cwd);
|
||||
}, [id, cwd, setClaudeMode]);
|
||||
window.electronAPI.invokeClaudeInTerminal(id, effectiveCwd);
|
||||
}, [id, effectiveCwd, setClaudeMode]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onActivate();
|
||||
@@ -153,6 +171,73 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title
|
||||
updateTerminal(id, { title: 'Claude' });
|
||||
}, [id, setAssociatedTask, updateTerminal]);
|
||||
|
||||
// Worktree handlers
|
||||
const handleCreateWorktree = useCallback(() => {
|
||||
setShowWorktreeDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleWorktreeCreated = useCallback(async (config: TerminalWorktreeConfig) => {
|
||||
// IMPORTANT: Set isCreatingRef BEFORE updating the store to prevent race condition
|
||||
// This prevents the PTY effect from running before destroyTerminal completes
|
||||
prepareForRecreate();
|
||||
|
||||
// Update terminal store with worktree config
|
||||
setWorktreeConfig(id, config);
|
||||
|
||||
// Update terminal title and cwd to worktree path
|
||||
updateTerminal(id, { title: config.name, cwd: config.worktreePath });
|
||||
|
||||
// Destroy current PTY - a new one will be created in the worktree directory
|
||||
if (isCreatedRef.current) {
|
||||
await window.electronAPI.destroyTerminal(id);
|
||||
isCreatedRef.current = false;
|
||||
}
|
||||
|
||||
// Reset refs to allow recreation - effect will now trigger with new cwd
|
||||
resetForRecreate();
|
||||
}, [id, setWorktreeConfig, updateTerminal, prepareForRecreate, resetForRecreate]);
|
||||
|
||||
const handleSelectWorktree = useCallback(async (config: TerminalWorktreeConfig) => {
|
||||
// IMPORTANT: Set isCreatingRef BEFORE updating the store to prevent race condition
|
||||
prepareForRecreate();
|
||||
|
||||
// Same logic as handleWorktreeCreated - attach terminal to existing worktree
|
||||
setWorktreeConfig(id, config);
|
||||
updateTerminal(id, { title: config.name, cwd: config.worktreePath });
|
||||
|
||||
// Destroy current PTY - a new one will be created in the worktree directory
|
||||
if (isCreatedRef.current) {
|
||||
await window.electronAPI.destroyTerminal(id);
|
||||
isCreatedRef.current = false;
|
||||
}
|
||||
|
||||
resetForRecreate();
|
||||
}, [id, setWorktreeConfig, updateTerminal, prepareForRecreate, resetForRecreate]);
|
||||
|
||||
const handleOpenInIDE = useCallback(async () => {
|
||||
const worktreePath = terminal?.worktreeConfig?.worktreePath;
|
||||
if (!worktreePath) return;
|
||||
|
||||
const preferredIDE = settings.preferredIDE || 'vscode';
|
||||
try {
|
||||
await window.electronAPI.worktreeOpenInIDE(
|
||||
worktreePath,
|
||||
preferredIDE,
|
||||
settings.customIDEPath
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to open in IDE:', err);
|
||||
toast({
|
||||
title: 'Failed to open IDE',
|
||||
description: err instanceof Error ? err.message : 'Could not launch IDE',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [terminal?.worktreeConfig?.worktreePath, settings.preferredIDE, settings.customIDEPath, toast]);
|
||||
|
||||
// Get backlog tasks for worktree dialog
|
||||
const backlogTasks = tasks.filter((t) => t.status === 'backlog');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setDropRef}
|
||||
@@ -186,6 +271,11 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title
|
||||
onClearTask={handleClearTask}
|
||||
onNewTaskClick={onNewTaskClick}
|
||||
terminalCount={terminalCount}
|
||||
worktreeConfig={terminal?.worktreeConfig}
|
||||
projectPath={projectPath}
|
||||
onCreateWorktree={handleCreateWorktree}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onOpenInIDE={handleOpenInIDE}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -193,6 +283,18 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title
|
||||
className="flex-1 p-1"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
|
||||
{/* Worktree creation dialog */}
|
||||
{projectPath && (
|
||||
<CreateWorktreeDialog
|
||||
open={showWorktreeDialog}
|
||||
onOpenChange={setShowWorktreeDialog}
|
||||
terminalId={id}
|
||||
projectPath={projectPath}
|
||||
backlogTasks={backlogTasks}
|
||||
onWorktreeCreated={handleWorktreeCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Separator } from '../../ui/separator';
|
||||
import { Button } from '../../ui/button';
|
||||
import { GitHubOAuthFlow } from '../../project-settings/GitHubOAuthFlow';
|
||||
import { PasswordInput } from '../../project-settings/PasswordInput';
|
||||
import type { ProjectEnvConfig, GitHubSyncStatus } from '../../../../shared/types';
|
||||
import type { ProjectEnvConfig, GitHubSyncStatus, ProjectSettings } from '../../../../shared/types';
|
||||
|
||||
// Debug logging
|
||||
const DEBUG = process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true';
|
||||
@@ -35,6 +35,9 @@ interface GitHubIntegrationProps {
|
||||
gitHubConnectionStatus: GitHubSyncStatus | null;
|
||||
isCheckingGitHub: boolean;
|
||||
projectPath?: string; // Project path for fetching git branches
|
||||
// Project settings for mainBranch (used by kanban tasks and terminal worktrees)
|
||||
settings?: ProjectSettings;
|
||||
setSettings?: React.Dispatch<React.SetStateAction<ProjectSettings>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +51,9 @@ export function GitHubIntegration({
|
||||
setShowGitHubToken: _setShowGitHubToken,
|
||||
gitHubConnectionStatus,
|
||||
isCheckingGitHub,
|
||||
projectPath
|
||||
projectPath,
|
||||
settings,
|
||||
setSettings
|
||||
}: GitHubIntegrationProps) {
|
||||
const [authMode, setAuthMode] = useState<'manual' | 'oauth' | 'oauth-success'>('manual');
|
||||
const [oauthUsername, setOauthUsername] = useState<string | null>(null);
|
||||
@@ -84,6 +89,24 @@ export function GitHubIntegration({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [envConfig?.githubEnabled, projectPath]);
|
||||
|
||||
/**
|
||||
* Handler for branch selection changes.
|
||||
* Updates BOTH project.settings.mainBranch (for Electron app) and envConfig.defaultBranch (for CLI backward compatibility).
|
||||
*/
|
||||
const handleBranchChange = (branch: string) => {
|
||||
debugLog('handleBranchChange: Updating branch to:', branch);
|
||||
|
||||
// Update project settings (primary source for Electron app)
|
||||
if (setSettings) {
|
||||
setSettings(prev => ({ ...prev, mainBranch: branch }));
|
||||
debugLog('handleBranchChange: Updated settings.mainBranch');
|
||||
}
|
||||
|
||||
// Also update envConfig for CLI backward compatibility
|
||||
updateEnvConfig({ defaultBranch: branch });
|
||||
debugLog('handleBranchChange: Updated envConfig.defaultBranch');
|
||||
};
|
||||
|
||||
const fetchBranches = async () => {
|
||||
if (!projectPath) {
|
||||
debugLog('fetchBranches: No projectPath, skipping');
|
||||
@@ -104,14 +127,15 @@ export function GitHubIntegration({
|
||||
setBranches(result.data);
|
||||
debugLog('fetchBranches: Loaded branches:', result.data.length);
|
||||
|
||||
// Auto-detect default branch if not set
|
||||
if (!envConfig?.defaultBranch) {
|
||||
debugLog('fetchBranches: No defaultBranch set, auto-detecting...');
|
||||
// Auto-detect default branch if not set in project settings
|
||||
// Priority: settings.mainBranch > envConfig.defaultBranch > auto-detect
|
||||
if (!settings?.mainBranch && !envConfig?.defaultBranch) {
|
||||
debugLog('fetchBranches: No branch set, auto-detecting...');
|
||||
const detectResult = await window.electronAPI.detectMainBranch(projectPath);
|
||||
debugLog('fetchBranches: detectMainBranch result:', detectResult);
|
||||
if (detectResult.success && detectResult.data) {
|
||||
debugLog('fetchBranches: Auto-detected default branch:', detectResult.data);
|
||||
updateEnvConfig({ defaultBranch: detectResult.data });
|
||||
handleBranchChange(detectResult.data);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -314,10 +338,10 @@ export function GitHubIntegration({
|
||||
{projectPath && (
|
||||
<BranchSelector
|
||||
branches={branches}
|
||||
selectedBranch={envConfig.defaultBranch || ''}
|
||||
selectedBranch={settings?.mainBranch || envConfig.defaultBranch || ''}
|
||||
isLoading={isLoadingBranches}
|
||||
error={branchesError}
|
||||
onSelect={(branch) => updateEnvConfig({ defaultBranch: branch })}
|
||||
onSelect={handleBranchChange}
|
||||
onRefresh={fetchBranches}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Switch } from '../../ui/switch';
|
||||
import { Separator } from '../../ui/separator';
|
||||
import { Button } from '../../ui/button';
|
||||
import { PasswordInput } from '../../project-settings/PasswordInput';
|
||||
import type { ProjectEnvConfig, GitLabSyncStatus } from '../../../../shared/types';
|
||||
import type { ProjectEnvConfig, GitLabSyncStatus, ProjectSettings } from '../../../../shared/types';
|
||||
|
||||
// Debug logging
|
||||
const DEBUG = process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true';
|
||||
@@ -35,6 +35,9 @@ interface GitLabIntegrationProps {
|
||||
gitLabConnectionStatus: GitLabSyncStatus | null;
|
||||
isCheckingGitLab: boolean;
|
||||
projectPath?: string;
|
||||
// Project settings for mainBranch (used by kanban tasks and terminal worktrees)
|
||||
settings?: ProjectSettings;
|
||||
setSettings?: React.Dispatch<React.SetStateAction<ProjectSettings>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +52,9 @@ export function GitLabIntegration({
|
||||
setShowGitLabToken: _setShowGitLabToken,
|
||||
gitLabConnectionStatus,
|
||||
isCheckingGitLab,
|
||||
projectPath
|
||||
projectPath,
|
||||
settings,
|
||||
setSettings
|
||||
}: GitLabIntegrationProps) {
|
||||
const { t } = useTranslation('gitlab');
|
||||
const [authMode, setAuthMode] = useState<'manual' | 'oauth' | 'oauth-success'>('manual');
|
||||
@@ -116,6 +121,24 @@ export function GitLabIntegration({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [envConfig?.gitlabEnabled, projectPath]);
|
||||
|
||||
/**
|
||||
* Handler for branch selection changes.
|
||||
* Updates BOTH project.settings.mainBranch (for Electron app) and envConfig.defaultBranch (for CLI backward compatibility).
|
||||
*/
|
||||
const handleBranchChange = (branch: string) => {
|
||||
debugLog('handleBranchChange: Updating branch to:', branch);
|
||||
|
||||
// Update project settings (primary source for Electron app)
|
||||
if (setSettings) {
|
||||
setSettings(prev => ({ ...prev, mainBranch: branch }));
|
||||
debugLog('handleBranchChange: Updated settings.mainBranch');
|
||||
}
|
||||
|
||||
// Also update envConfig for CLI backward compatibility
|
||||
updateEnvConfig({ defaultBranch: branch });
|
||||
debugLog('handleBranchChange: Updated envConfig.defaultBranch');
|
||||
};
|
||||
|
||||
const fetchBranches = async () => {
|
||||
if (!projectPath) {
|
||||
debugLog('fetchBranches: No projectPath, skipping');
|
||||
@@ -135,14 +158,15 @@ export function GitLabIntegration({
|
||||
setBranches(result.data);
|
||||
debugLog('fetchBranches: Loaded branches:', result.data.length);
|
||||
|
||||
// Auto-detect default branch if not set
|
||||
if (!envConfig?.defaultBranch) {
|
||||
debugLog('fetchBranches: No defaultBranch set, auto-detecting...');
|
||||
// Auto-detect default branch if not set in project settings
|
||||
// Priority: settings.mainBranch > envConfig.defaultBranch > auto-detect
|
||||
if (!settings?.mainBranch && !envConfig?.defaultBranch) {
|
||||
debugLog('fetchBranches: No branch set, auto-detecting...');
|
||||
const detectResult = await window.electronAPI.detectMainBranch(projectPath);
|
||||
debugLog('fetchBranches: detectMainBranch result:', detectResult);
|
||||
if (detectResult.success && detectResult.data) {
|
||||
debugLog('fetchBranches: Auto-detected default branch:', detectResult.data);
|
||||
updateEnvConfig({ defaultBranch: detectResult.data });
|
||||
handleBranchChange(detectResult.data);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -515,10 +539,10 @@ export function GitLabIntegration({
|
||||
{projectPath && (
|
||||
<BranchSelector
|
||||
branches={branches}
|
||||
selectedBranch={envConfig.defaultBranch || ''}
|
||||
selectedBranch={settings?.mainBranch || envConfig.defaultBranch || ''}
|
||||
isLoading={isLoadingBranches}
|
||||
error={branchesError}
|
||||
onSelect={(branch) => updateEnvConfig({ defaultBranch: branch })}
|
||||
onSelect={handleBranchChange}
|
||||
onRefresh={fetchBranches}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -136,6 +136,8 @@ export function SectionRouter({
|
||||
gitHubConnectionStatus={gitHubConnectionStatus}
|
||||
isCheckingGitHub={isCheckingGitHub}
|
||||
projectPath={project.path}
|
||||
settings={settings}
|
||||
setSettings={setSettings}
|
||||
/>
|
||||
</InitializationGuard>
|
||||
</SettingsSection>
|
||||
@@ -160,6 +162,8 @@ export function SectionRouter({
|
||||
gitLabConnectionStatus={gitLabConnectionStatus}
|
||||
isCheckingGitLab={isCheckingGitLab}
|
||||
projectPath={project.path}
|
||||
settings={settings}
|
||||
setSettings={setSettings}
|
||||
/>
|
||||
</InitializationGuard>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GitBranch, Loader2, FolderGit, ListTodo } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import type { Task, TerminalWorktreeConfig } from '../../../shared/types';
|
||||
import { useProjectStore } from '../../stores/project-store';
|
||||
|
||||
// Special value to represent "use project default" since Radix UI Select doesn't allow empty string values
|
||||
const PROJECT_DEFAULT_BRANCH = '__project_default__';
|
||||
|
||||
interface CreateWorktreeDialogProps {
|
||||
/** Whether the dialog is open */
|
||||
open: boolean;
|
||||
/** Callback when the dialog open state changes */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Terminal ID to associate with the worktree */
|
||||
terminalId: string;
|
||||
/** Project path for worktree creation */
|
||||
projectPath: string;
|
||||
/** Available backlog tasks for linking */
|
||||
backlogTasks: Task[];
|
||||
/** Callback when worktree is successfully created */
|
||||
onWorktreeCreated: (config: TerminalWorktreeConfig) => void;
|
||||
}
|
||||
|
||||
export function CreateWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
terminalId,
|
||||
projectPath,
|
||||
backlogTasks,
|
||||
onWorktreeCreated,
|
||||
}: CreateWorktreeDialogProps) {
|
||||
const { t } = useTranslation(['terminal', 'common']);
|
||||
const [name, setName] = useState('');
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>();
|
||||
const [createGitBranch, setCreateGitBranch] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get project settings for default branch
|
||||
const project = useProjectStore((state) =>
|
||||
state.projects.find((p) => p.path === projectPath)
|
||||
);
|
||||
|
||||
// Branch selection state
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
|
||||
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
|
||||
|
||||
// Fetch branches when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && projectPath) {
|
||||
const fetchBranches = async () => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const result = await window.electronAPI.getGitBranches(projectPath);
|
||||
if (result.success && result.data) {
|
||||
setBranches(result.data);
|
||||
}
|
||||
|
||||
// Use project settings mainBranch if available, otherwise auto-detect
|
||||
if (project?.settings?.mainBranch) {
|
||||
setProjectDefaultBranch(project.settings.mainBranch);
|
||||
} else {
|
||||
// Fallback to auto-detect if no project setting
|
||||
const defaultResult = await window.electronAPI.detectMainBranch(projectPath);
|
||||
if (defaultResult.success && defaultResult.data) {
|
||||
setProjectDefaultBranch(defaultResult.data);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch branches:', err);
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
fetchBranches();
|
||||
}
|
||||
}, [open, projectPath, project?.settings?.mainBranch]);
|
||||
|
||||
const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Auto-sanitize: lowercase, replace spaces and invalid chars
|
||||
const sanitized = e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-_]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
setName(sanitized);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleTaskSelect = useCallback((taskId: string) => {
|
||||
if (taskId === 'none') {
|
||||
setSelectedTaskId(undefined);
|
||||
return;
|
||||
}
|
||||
setSelectedTaskId(taskId);
|
||||
// Auto-fill name from task if empty
|
||||
if (!name) {
|
||||
const task = backlogTasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
// Convert task title to valid name
|
||||
const autoName = task.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 30);
|
||||
setName(autoName);
|
||||
}
|
||||
}
|
||||
}, [backlogTasks, name]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
setError(t('terminal:worktree.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
|
||||
setError(t('terminal:worktree.nameInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.createTerminalWorktree({
|
||||
terminalId,
|
||||
name: name.trim(),
|
||||
taskId: selectedTaskId,
|
||||
createGitBranch,
|
||||
projectPath,
|
||||
// Only include baseBranch if not using project default
|
||||
baseBranch: baseBranch !== PROJECT_DEFAULT_BRANCH ? baseBranch : undefined,
|
||||
});
|
||||
|
||||
if (result.success && result.config) {
|
||||
onWorktreeCreated(result.config);
|
||||
onOpenChange(false);
|
||||
// Reset form
|
||||
setName('');
|
||||
setSelectedTaskId(undefined);
|
||||
setCreateGitBranch(true);
|
||||
} else {
|
||||
setError(result.error || t('common:errors.generic'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('common:errors.generic'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
// Reset form on close
|
||||
setName('');
|
||||
setSelectedTaskId(undefined);
|
||||
setCreateGitBranch(true);
|
||||
setBaseBranch(PROJECT_DEFAULT_BRANCH);
|
||||
setError(null);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderGit className="h-5 w-5" />
|
||||
{t('terminal:worktree.createTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('terminal:worktree.createDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Worktree Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="worktree-name">{t('terminal:worktree.name')}</Label>
|
||||
<Input
|
||||
id="worktree-name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder={t('terminal:worktree.namePlaceholder')}
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('terminal:worktree.nameHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Task Association (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<ListTodo className="h-4 w-4" />
|
||||
{t('terminal:worktree.associateTask')}
|
||||
<span className="text-muted-foreground text-xs">({t('common:labels.optional')})</span>
|
||||
</Label>
|
||||
<Select value={selectedTaskId || 'none'} onValueChange={handleTaskSelect}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('terminal:worktree.selectTask')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('terminal:worktree.noTask')}</SelectItem>
|
||||
{backlogTasks.slice(0, 10).map((task) => (
|
||||
<SelectItem key={task.id} value={task.id}>
|
||||
<span className="truncate max-w-[300px]">{task.title}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Git Branch Toggle */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="create-branch" className="flex items-center gap-2 cursor-pointer">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
{t('terminal:worktree.createBranch')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('terminal:worktree.branchHelp', { branch: `terminal/${name || 'name'}` })}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="create-branch"
|
||||
checked={createGitBranch}
|
||||
onCheckedChange={setCreateGitBranch}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Base Branch Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="base-branch" className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
{t('terminal:worktree.baseBranch')}
|
||||
</Label>
|
||||
<Select
|
||||
value={baseBranch}
|
||||
onValueChange={setBaseBranch}
|
||||
disabled={isCreating || isLoadingBranches}
|
||||
>
|
||||
<SelectTrigger id="base-branch">
|
||||
<SelectValue placeholder={t('terminal:worktree.selectBaseBranch')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PROJECT_DEFAULT_BRANCH}>
|
||||
{t('terminal:worktree.useProjectDefault', { branch: projectDefaultBranch || 'main' })}
|
||||
</SelectItem>
|
||||
{branches
|
||||
.filter(b => b !== projectDefaultBranch)
|
||||
.slice(0, 15)
|
||||
.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
{branch}
|
||||
</SelectItem>
|
||||
))}
|
||||
{projectDefaultBranch && !branches.includes(projectDefaultBranch) && (
|
||||
<SelectItem value={projectDefaultBranch}>
|
||||
{projectDefaultBranch}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('terminal:worktree.baseBranchHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
|
||||
{t('common:buttons.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating || !name.trim()}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('common:labels.creating')}
|
||||
</>
|
||||
) : (
|
||||
t('common:buttons.create')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { X, Sparkles, TerminalSquare } from 'lucide-react';
|
||||
import type { Task } from '../../../shared/types';
|
||||
import { X, Sparkles, TerminalSquare, FolderGit, ExternalLink } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Task, TerminalWorktreeConfig } from '../../../shared/types';
|
||||
import type { TerminalStatus } from '../../stores/terminal-store';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { STATUS_COLORS } from './types';
|
||||
import { TerminalTitle } from './TerminalTitle';
|
||||
import { TaskSelector } from './TaskSelector';
|
||||
import { WorktreeSelector } from './WorktreeSelector';
|
||||
|
||||
interface TerminalHeaderProps {
|
||||
terminalId: string;
|
||||
@@ -21,6 +23,16 @@ interface TerminalHeaderProps {
|
||||
onClearTask: () => void;
|
||||
onNewTaskClick?: () => void;
|
||||
terminalCount?: number;
|
||||
/** Worktree configuration if terminal is associated with a worktree */
|
||||
worktreeConfig?: TerminalWorktreeConfig;
|
||||
/** Project path for worktree operations */
|
||||
projectPath?: string;
|
||||
/** Callback to open worktree creation dialog */
|
||||
onCreateWorktree?: () => void;
|
||||
/** Callback when an existing worktree is selected */
|
||||
onSelectWorktree?: (config: TerminalWorktreeConfig) => void;
|
||||
/** Callback to open worktree in IDE */
|
||||
onOpenInIDE?: () => void;
|
||||
}
|
||||
|
||||
export function TerminalHeader({
|
||||
@@ -37,7 +49,13 @@ export function TerminalHeader({
|
||||
onClearTask,
|
||||
onNewTaskClick,
|
||||
terminalCount = 1,
|
||||
worktreeConfig,
|
||||
projectPath,
|
||||
onCreateWorktree,
|
||||
onSelectWorktree,
|
||||
onOpenInIDE,
|
||||
}: TerminalHeaderProps) {
|
||||
const { t } = useTranslation(['terminal', 'common']);
|
||||
const backlogTasks = tasks.filter((t) => t.status === 'backlog');
|
||||
|
||||
return (
|
||||
@@ -69,8 +87,40 @@ export function TerminalHeader({
|
||||
onNewTaskClick={onNewTaskClick}
|
||||
/>
|
||||
)}
|
||||
{/* Worktree badge when associated */}
|
||||
{worktreeConfig && (
|
||||
<span className="flex items-center gap-1 text-[10px] font-medium text-amber-500 bg-amber-500/10 px-1.5 py-0.5 rounded">
|
||||
<FolderGit className="h-2.5 w-2.5" />
|
||||
{worktreeConfig.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Worktree selector when no worktree and project path available */}
|
||||
{!worktreeConfig && projectPath && onCreateWorktree && onSelectWorktree && (
|
||||
<WorktreeSelector
|
||||
terminalId={terminalId}
|
||||
projectPath={projectPath}
|
||||
currentWorktree={worktreeConfig}
|
||||
onCreateWorktree={onCreateWorktree}
|
||||
onSelectWorktree={onSelectWorktree}
|
||||
/>
|
||||
)}
|
||||
{/* Open in IDE button when worktree exists */}
|
||||
{worktreeConfig && onOpenInIDE && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs gap-1 hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenInIDE();
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t('terminal:worktree.openInIDE')}
|
||||
</Button>
|
||||
)}
|
||||
{!isClaudeMode && status !== 'exited' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FolderGit, Plus, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TerminalWorktreeConfig } from '../../../shared/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface WorktreeSelectorProps {
|
||||
terminalId: string;
|
||||
projectPath: string;
|
||||
/** Currently attached worktree config, if any */
|
||||
currentWorktree?: TerminalWorktreeConfig;
|
||||
/** Callback to create a new worktree */
|
||||
onCreateWorktree: () => void;
|
||||
/** Callback when an existing worktree is selected */
|
||||
onSelectWorktree: (config: TerminalWorktreeConfig) => void;
|
||||
}
|
||||
|
||||
export function WorktreeSelector({
|
||||
terminalId: _terminalId,
|
||||
projectPath,
|
||||
currentWorktree,
|
||||
onCreateWorktree,
|
||||
onSelectWorktree,
|
||||
}: WorktreeSelectorProps) {
|
||||
const { t } = useTranslation(['terminal', 'common']);
|
||||
const [worktrees, setWorktrees] = useState<TerminalWorktreeConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Fetch worktrees when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && projectPath) {
|
||||
const fetchWorktrees = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await window.electronAPI.listTerminalWorktrees(projectPath);
|
||||
if (result.success && result.data) {
|
||||
// Filter out the current worktree from the list
|
||||
const available = currentWorktree
|
||||
? result.data.filter((wt) => wt.name !== currentWorktree.name)
|
||||
: result.data;
|
||||
setWorktrees(available);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch worktrees:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchWorktrees();
|
||||
}
|
||||
}, [isOpen, projectPath, currentWorktree]);
|
||||
|
||||
// If terminal already has a worktree, show worktree badge (handled in TerminalHeader)
|
||||
// This component only shows when there's no worktree attached
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1 h-6 px-2 rounded text-xs font-medium transition-colors',
|
||||
'hover:bg-amber-500/10 hover:text-amber-500 text-muted-foreground'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FolderGit className="h-3 w-3" />
|
||||
<span>{t('terminal:worktree.create')}</span>
|
||||
<ChevronDown className="h-2.5 w-2.5 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{/* New Worktree - always at top */}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
onCreateWorktree();
|
||||
}}
|
||||
className="text-xs text-amber-500"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-2" />
|
||||
{t('terminal:worktree.createNew')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Separator and existing worktrees */}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
) : worktrees.length > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t('terminal:worktree.existing')}
|
||||
</div>
|
||||
{worktrees.map((wt) => (
|
||||
<DropdownMenuItem
|
||||
key={wt.name}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
onSelectWorktree(wt);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<FolderGit className="h-3 w-3 mr-2 text-amber-500/70" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate font-medium">{wt.name}</span>
|
||||
{wt.branchName && (
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{wt.branchName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useTerminalStore } from '../../stores/terminal-store';
|
||||
|
||||
interface UsePtyProcessOptions {
|
||||
@@ -22,9 +22,22 @@ export function usePtyProcess({
|
||||
}: UsePtyProcessOptions) {
|
||||
const isCreatingRef = useRef(false);
|
||||
const isCreatedRef = useRef(false);
|
||||
const currentCwdRef = useRef(cwd);
|
||||
const setTerminalStatus = useTerminalStore((state) => state.setTerminalStatus);
|
||||
const updateTerminal = useTerminalStore((state) => state.updateTerminal);
|
||||
|
||||
// Track cwd changes - if cwd changes while terminal exists, trigger recreate
|
||||
useEffect(() => {
|
||||
if (currentCwdRef.current !== cwd) {
|
||||
if (isCreatedRef.current) {
|
||||
// Terminal exists, reset refs to allow recreation
|
||||
isCreatedRef.current = false;
|
||||
isCreatingRef.current = false;
|
||||
}
|
||||
currentCwdRef.current = cwd;
|
||||
}
|
||||
}, [cwd]);
|
||||
|
||||
// Create PTY process
|
||||
useEffect(() => {
|
||||
if (isCreatingRef.current || isCreatedRef.current) return;
|
||||
@@ -92,7 +105,22 @@ export function usePtyProcess({
|
||||
}
|
||||
}, [terminalId, cwd, projectPath, cols, rows, setTerminalStatus, updateTerminal, onCreated, onError]);
|
||||
|
||||
// Function to prepare for recreation by preventing the effect from running
|
||||
// Call this BEFORE updating the store cwd to avoid race condition
|
||||
const prepareForRecreate = useCallback(() => {
|
||||
isCreatingRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Function to reset refs and allow recreation
|
||||
// Call this AFTER destroying the old terminal
|
||||
const resetForRecreate = useCallback(() => {
|
||||
isCreatedRef.current = false;
|
||||
isCreatingRef.current = false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isCreated: isCreatedRef.current,
|
||||
prepareForRecreate,
|
||||
resetForRecreate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -246,6 +246,20 @@ const browserMockAPI: ElectronAPI = {
|
||||
data: { command: 'npm install -g @anthropic-ai/claude-code' }
|
||||
}),
|
||||
|
||||
// Terminal Worktree Operations
|
||||
createTerminalWorktree: async () => ({
|
||||
success: false,
|
||||
error: 'Not available in browser mode'
|
||||
}),
|
||||
listTerminalWorktrees: async () => ({
|
||||
success: true,
|
||||
data: []
|
||||
}),
|
||||
removeTerminalWorktree: async () => ({
|
||||
success: false,
|
||||
error: 'Not available in browser mode'
|
||||
}),
|
||||
|
||||
// MCP Server Health Check Operations
|
||||
checkMcpHealth: async (server) => ({
|
||||
success: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { TerminalSession } from '../../shared/types';
|
||||
import type { TerminalSession, TerminalWorktreeConfig } from '../../shared/types';
|
||||
import { terminalBufferManager } from '../lib/terminal-buffer-manager';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Terminal {
|
||||
isRestored?: boolean; // Whether this terminal was restored from a saved session
|
||||
associatedTaskId?: string; // ID of task associated with this terminal (for context loading)
|
||||
projectPath?: string; // Project this terminal belongs to (for multi-project support)
|
||||
worktreeConfig?: TerminalWorktreeConfig; // Associated worktree for isolated development
|
||||
}
|
||||
|
||||
interface TerminalLayout {
|
||||
@@ -45,6 +46,7 @@ interface TerminalState {
|
||||
setClaudeMode: (id: string, isClaudeMode: boolean) => void;
|
||||
setClaudeSessionId: (id: string, sessionId: string) => void;
|
||||
setAssociatedTask: (id: string, taskId: string | undefined) => void;
|
||||
setWorktreeConfig: (id: string, config: TerminalWorktreeConfig | undefined) => void;
|
||||
clearAllTerminals: () => void;
|
||||
setHasRestoredSessions: (value: boolean) => void;
|
||||
|
||||
@@ -53,6 +55,7 @@ interface TerminalState {
|
||||
getActiveTerminal: () => Terminal | undefined;
|
||||
canAddTerminal: () => boolean;
|
||||
getTerminalsForProject: (projectPath: string) => Terminal[];
|
||||
getWorktreeCount: () => number;
|
||||
}
|
||||
|
||||
export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
@@ -185,6 +188,14 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
setWorktreeConfig: (id: string, config: TerminalWorktreeConfig | undefined) => {
|
||||
set((state) => ({
|
||||
terminals: state.terminals.map((t) =>
|
||||
t.id === id ? { ...t, worktreeConfig: config } : t
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearAllTerminals: () => {
|
||||
set({ terminals: [], activeTerminalId: null, hasRestoredSessions: false });
|
||||
},
|
||||
@@ -210,6 +221,10 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
getTerminalsForProject: (projectPath: string) => {
|
||||
return get().terminals.filter(t => t.projectPath === projectPath);
|
||||
},
|
||||
|
||||
getWorktreeCount: () => {
|
||||
return get().terminals.filter(t => t.worktreeConfig).length;
|
||||
},
|
||||
}));
|
||||
|
||||
// Track in-progress restore operations to prevent race conditions
|
||||
|
||||
@@ -74,6 +74,11 @@ export const IPC_CHANNELS = {
|
||||
TERMINAL_RESTORE_FROM_DATE: 'terminal:restoreFromDate',
|
||||
TERMINAL_CHECK_PTY_ALIVE: 'terminal:checkPtyAlive',
|
||||
|
||||
// Terminal worktree operations (isolated development in worktrees)
|
||||
TERMINAL_WORKTREE_CREATE: 'terminal:worktreeCreate',
|
||||
TERMINAL_WORKTREE_REMOVE: 'terminal:worktreeRemove',
|
||||
TERMINAL_WORKTREE_LIST: 'terminal:worktreeList',
|
||||
|
||||
// Terminal events (main -> renderer)
|
||||
TERMINAL_OUTPUT: 'terminal:output',
|
||||
TERMINAL_EXIT: 'terminal:exit',
|
||||
|
||||
@@ -11,6 +11,7 @@ import enOnboarding from './locales/en/onboarding.json';
|
||||
import enDialogs from './locales/en/dialogs.json';
|
||||
import enGitlab from './locales/en/gitlab.json';
|
||||
import enTaskReview from './locales/en/taskReview.json';
|
||||
import enTerminal from './locales/en/terminal.json';
|
||||
|
||||
// Import French translation resources
|
||||
import frCommon from './locales/fr/common.json';
|
||||
@@ -22,6 +23,7 @@ import frOnboarding from './locales/fr/onboarding.json';
|
||||
import frDialogs from './locales/fr/dialogs.json';
|
||||
import frGitlab from './locales/fr/gitlab.json';
|
||||
import frTaskReview from './locales/fr/taskReview.json';
|
||||
import frTerminal from './locales/fr/terminal.json';
|
||||
|
||||
export const defaultNS = 'common';
|
||||
|
||||
@@ -35,7 +37,8 @@ export const resources = {
|
||||
onboarding: enOnboarding,
|
||||
dialogs: enDialogs,
|
||||
gitlab: enGitlab,
|
||||
taskReview: enTaskReview
|
||||
taskReview: enTaskReview,
|
||||
terminal: enTerminal
|
||||
},
|
||||
fr: {
|
||||
common: frCommon,
|
||||
@@ -46,7 +49,8 @@ export const resources = {
|
||||
onboarding: frOnboarding,
|
||||
dialogs: frDialogs,
|
||||
gitlab: frGitlab,
|
||||
taskReview: frTaskReview
|
||||
taskReview: frTaskReview,
|
||||
terminal: frTerminal
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -57,7 +61,7 @@ i18n
|
||||
lng: 'en', // Default language (will be overridden by settings)
|
||||
fallbackLng: 'en',
|
||||
defaultNS,
|
||||
ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'gitlab', 'taskReview'],
|
||||
ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'gitlab', 'taskReview', 'terminal'],
|
||||
interpolation: {
|
||||
escapeValue: false // React already escapes values
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"success": "Success",
|
||||
"initializing": "Initializing...",
|
||||
"saving": "Saving...",
|
||||
"creating": "Creating...",
|
||||
"noData": "No data",
|
||||
"optional": "Optional",
|
||||
"required": "Required",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"worktree": {
|
||||
"create": "Worktree",
|
||||
"createNew": "+ New Worktree",
|
||||
"existing": "Existing Worktrees",
|
||||
"createTitle": "Create Terminal Worktree",
|
||||
"createDescription": "Create an isolated workspace for this terminal. All work will happen in the worktree directory.",
|
||||
"name": "Worktree Name",
|
||||
"namePlaceholder": "my-feature",
|
||||
"nameRequired": "Worktree name is required",
|
||||
"nameInvalid": "Name must start and end with a letter or number",
|
||||
"nameHelp": "Lowercase letters, numbers, dashes, and underscores only",
|
||||
"associateTask": "Link to Task",
|
||||
"selectTask": "Select a task...",
|
||||
"noTask": "No task (standalone worktree)",
|
||||
"createBranch": "Create Git Branch",
|
||||
"branchHelp": "Creates branch: {{branch}}",
|
||||
"baseBranch": "Base Branch",
|
||||
"selectBaseBranch": "Select base branch...",
|
||||
"useProjectDefault": "Use project default ({{branch}})",
|
||||
"baseBranchHelp": "The branch to create the worktree from",
|
||||
"openInIDE": "Open in IDE",
|
||||
"maxReached": "Maximum of 12 terminal worktrees reached",
|
||||
"alreadyExists": "A worktree with this name already exists"
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@
|
||||
"success": "Succès",
|
||||
"initializing": "Initialisation...",
|
||||
"saving": "Enregistrement...",
|
||||
"creating": "Creation...",
|
||||
"noData": "Aucune donnée",
|
||||
"optional": "Optionnel",
|
||||
"required": "Requis",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"worktree": {
|
||||
"create": "Worktree",
|
||||
"createNew": "+ Nouveau Worktree",
|
||||
"existing": "Worktrees Existants",
|
||||
"createTitle": "Creer un Worktree Terminal",
|
||||
"createDescription": "Creer un espace de travail isole pour ce terminal. Tout le travail se fera dans le repertoire du worktree.",
|
||||
"name": "Nom du Worktree",
|
||||
"namePlaceholder": "ma-fonctionnalite",
|
||||
"nameRequired": "Le nom du worktree est requis",
|
||||
"nameInvalid": "Le nom doit commencer et se terminer par une lettre ou un chiffre",
|
||||
"nameHelp": "Lettres minuscules, chiffres, tirets et underscores uniquement",
|
||||
"associateTask": "Lier a une Tache",
|
||||
"selectTask": "Selectionner une tache...",
|
||||
"noTask": "Pas de tache (worktree autonome)",
|
||||
"createBranch": "Creer une Branche Git",
|
||||
"branchHelp": "Cree la branche: {{branch}}",
|
||||
"baseBranch": "Branche de Base",
|
||||
"selectBaseBranch": "Selectionner la branche de base...",
|
||||
"useProjectDefault": "Utiliser la valeur par defaut du projet ({{branch}})",
|
||||
"baseBranchHelp": "La branche a partir de laquelle creer le worktree",
|
||||
"openInIDE": "Ouvrir dans IDE",
|
||||
"maxReached": "Maximum de 12 worktrees terminal atteint",
|
||||
"alreadyExists": "Un worktree avec ce nom existe deja"
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,10 @@ import type {
|
||||
SessionDateRestoreResult,
|
||||
RateLimitInfo,
|
||||
SDKRateLimitInfo,
|
||||
RetryWithProfileRequest
|
||||
RetryWithProfileRequest,
|
||||
CreateTerminalWorktreeRequest,
|
||||
TerminalWorktreeConfig,
|
||||
TerminalWorktreeResult,
|
||||
} from './terminal';
|
||||
import type {
|
||||
ClaudeProfileSettings,
|
||||
@@ -200,6 +203,11 @@ export interface ElectronAPI {
|
||||
saveTerminalBuffer: (terminalId: string, serialized: string) => Promise<void>;
|
||||
checkTerminalPtyAlive: (terminalId: string) => Promise<IPCResult<{ alive: boolean }>>;
|
||||
|
||||
// Terminal worktree operations (isolated development)
|
||||
createTerminalWorktree: (request: CreateTerminalWorktreeRequest) => Promise<TerminalWorktreeResult>;
|
||||
listTerminalWorktrees: (projectPath: string) => Promise<IPCResult<TerminalWorktreeConfig[]>>;
|
||||
removeTerminalWorktree: (projectPath: string, name: string, deleteBranch?: boolean) => Promise<IPCResult>;
|
||||
|
||||
// Terminal event listeners
|
||||
onTerminalOutput: (callback: (id: string, data: string) => void) => () => void;
|
||||
onTerminalExit: (callback: (id: string, exitCode: number) => void) => () => void;
|
||||
|
||||
@@ -132,3 +132,57 @@ export interface RetryWithProfileRequest {
|
||||
/** Profile ID to retry with */
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Terminal Worktree Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for a terminal-associated git worktree
|
||||
* Enables isolated development environments for each terminal session
|
||||
*/
|
||||
export interface TerminalWorktreeConfig {
|
||||
/** Unique worktree name (used as directory name) */
|
||||
name: string;
|
||||
/** Path to the worktree directory (.auto-claude/worktrees/terminal/{name}/) */
|
||||
worktreePath: string;
|
||||
/** Git branch name (terminal/{name}) - empty if no branch created */
|
||||
branchName: string;
|
||||
/** Base branch the worktree was created from (from project settings or auto-detected) */
|
||||
baseBranch: string;
|
||||
/** Whether a git branch was created for this worktree */
|
||||
hasGitBranch: boolean;
|
||||
/** Associated task ID (optional - for task-linked worktrees) */
|
||||
taskId?: string;
|
||||
/** When the worktree was created */
|
||||
createdAt: string;
|
||||
/** Terminal ID this worktree is associated with */
|
||||
terminalId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to create a terminal worktree
|
||||
*/
|
||||
export interface CreateTerminalWorktreeRequest {
|
||||
/** Terminal ID to associate with */
|
||||
terminalId: string;
|
||||
/** Worktree name (alphanumeric, dashes, underscores only) */
|
||||
name: string;
|
||||
/** Optional task ID to link */
|
||||
taskId?: string;
|
||||
/** Whether to create a git branch (terminal/{name}) */
|
||||
createGitBranch: boolean;
|
||||
/** Project path where the worktree will be created */
|
||||
projectPath: string;
|
||||
/** Optional base branch to create worktree from (defaults to project default) */
|
||||
baseBranch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of terminal worktree creation
|
||||
*/
|
||||
export interface TerminalWorktreeResult {
|
||||
success: boolean;
|
||||
config?: TerminalWorktreeConfig;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
+30
-16
@@ -161,14 +161,14 @@ class TestSetupWorkspace:
|
||||
assert working_dir.name == TEST_SPEC_NAME
|
||||
|
||||
def test_setup_isolated_creates_worktrees_dir(self, temp_git_repo: Path):
|
||||
"""Isolated mode creates .worktrees directory."""
|
||||
"""Isolated mode creates worktrees directory."""
|
||||
setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
assert (temp_git_repo / ".worktrees").exists()
|
||||
assert (temp_git_repo / ".auto-claude" / "worktrees" / "tasks").exists()
|
||||
|
||||
|
||||
class TestWorkspaceUtilities:
|
||||
@@ -185,7 +185,8 @@ class TestWorkspaceUtilities:
|
||||
|
||||
# Worktree should be named after the spec
|
||||
assert working_dir.name == spec_name
|
||||
assert working_dir.parent.name == ".worktrees"
|
||||
# New path: .auto-claude/worktrees/tasks/{spec_name}
|
||||
assert working_dir.parent.name == "tasks"
|
||||
|
||||
|
||||
class TestWorkspaceIntegration:
|
||||
@@ -236,12 +237,16 @@ class TestWorkspaceIntegration:
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Make changes and commit
|
||||
# Make changes and commit using git directly
|
||||
(working_dir / "feature.py").write_text("# New feature\n")
|
||||
manager.commit_in_staging("Add feature")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Add feature"],
|
||||
cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Merge back
|
||||
result = manager.merge_staging(delete_after=False)
|
||||
# Merge back using merge_worktree
|
||||
result = manager.merge_worktree("test-spec", delete_after=False)
|
||||
|
||||
assert result is True
|
||||
|
||||
@@ -264,12 +269,16 @@ class TestWorkspaceCleanup:
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Commit changes
|
||||
# Commit changes using git directly
|
||||
(working_dir / "test.py").write_text("test")
|
||||
manager.commit_in_staging("Test")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Test"],
|
||||
cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Merge with cleanup
|
||||
manager.merge_staging(delete_after=True)
|
||||
manager.merge_worktree("test-spec", delete_after=True)
|
||||
|
||||
# Workspace should be removed
|
||||
assert not working_dir.exists()
|
||||
@@ -282,12 +291,16 @@ class TestWorkspaceCleanup:
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Commit changes
|
||||
# Commit changes using git directly
|
||||
(working_dir / "test.py").write_text("test")
|
||||
manager.commit_in_staging("Test")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Test"],
|
||||
cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Merge without cleanup
|
||||
manager.merge_staging(delete_after=False)
|
||||
manager.merge_worktree("test-spec", delete_after=False)
|
||||
|
||||
# Workspace should still exist
|
||||
assert working_dir.exists()
|
||||
@@ -371,12 +384,13 @@ class TestPerSpecWorktreeName:
|
||||
assert working_dir1 != working_dir2
|
||||
|
||||
def test_worktree_path_in_worktrees_dir(self, temp_git_repo: Path):
|
||||
"""Worktree is created in .worktrees directory."""
|
||||
"""Worktree is created in worktrees directory."""
|
||||
working_dir, _, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
assert ".worktrees" in str(working_dir)
|
||||
assert working_dir.parent.name == ".worktrees"
|
||||
# New path: .auto-claude/worktrees/tasks/{spec_name}
|
||||
assert "worktrees" in str(working_dir)
|
||||
assert working_dir.parent.name == "tasks"
|
||||
|
||||
+19
-141
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from worktree import WorktreeManager, WorktreeInfo, WorktreeError, STAGING_WORKTREE_NAME
|
||||
from worktree import WorktreeManager, WorktreeInfo, WorktreeError
|
||||
|
||||
|
||||
class TestWorktreeManagerInitialization:
|
||||
@@ -27,7 +27,7 @@ class TestWorktreeManagerInitialization:
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
|
||||
assert manager.project_dir == temp_git_repo
|
||||
assert manager.worktrees_dir == temp_git_repo / ".worktrees"
|
||||
assert manager.worktrees_dir == temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
assert manager.base_branch is not None
|
||||
|
||||
def test_init_prefers_main_over_current_branch(self, temp_git_repo: Path):
|
||||
@@ -63,7 +63,7 @@ class TestWorktreeManagerInitialization:
|
||||
assert manager.base_branch == "main"
|
||||
|
||||
def test_setup_creates_worktrees_directory(self, temp_git_repo: Path):
|
||||
"""Setup creates the .worktrees directory."""
|
||||
"""Setup creates the worktrees directory."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
|
||||
@@ -112,75 +112,6 @@ class TestWorktreeCreation:
|
||||
assert (info2.path / "test-file.txt").exists()
|
||||
|
||||
|
||||
class TestStagingWorktree:
|
||||
"""Tests for staging worktree operations (backward compatibility)."""
|
||||
|
||||
def test_get_or_create_staging_creates_new(self, temp_git_repo: Path):
|
||||
"""Creates staging worktree if it doesn't exist."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
|
||||
info = manager.get_or_create_staging("test-spec")
|
||||
|
||||
assert info.path.exists()
|
||||
# Staging is now per-spec, worktree is named after spec
|
||||
assert info.path.name == "test-spec"
|
||||
assert "auto-claude/test-spec" in info.branch
|
||||
|
||||
def test_get_or_create_staging_returns_existing(self, temp_git_repo: Path):
|
||||
"""Returns existing staging worktree without recreating."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
|
||||
info1 = manager.get_or_create_staging("test-spec")
|
||||
# Add a file
|
||||
(info1.path / "marker.txt").write_text("marker")
|
||||
|
||||
info2 = manager.get_or_create_staging("test-spec")
|
||||
|
||||
# Should be the same worktree (marker file exists)
|
||||
assert (info2.path / "marker.txt").exists()
|
||||
|
||||
def test_staging_exists_false_when_none(self, temp_git_repo: Path):
|
||||
"""staging_exists returns False when no staging worktree."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
|
||||
assert manager.staging_exists() is False
|
||||
|
||||
def test_staging_exists_true_when_created(self, temp_git_repo: Path):
|
||||
"""staging_exists returns True after creating staging."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
manager.get_or_create_staging("test-spec")
|
||||
|
||||
assert manager.staging_exists() is True
|
||||
|
||||
def test_get_staging_path(self, temp_git_repo: Path):
|
||||
"""get_staging_path returns correct path."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
manager.get_or_create_staging("test-spec")
|
||||
|
||||
path = manager.get_staging_path()
|
||||
|
||||
assert path is not None
|
||||
# Staging is now per-spec, path is named after spec
|
||||
assert path.name == "test-spec"
|
||||
|
||||
def test_get_staging_info(self, temp_git_repo: Path):
|
||||
"""get_staging_info returns WorktreeInfo."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
manager.get_or_create_staging("test-spec")
|
||||
|
||||
info = manager.get_staging_info()
|
||||
|
||||
assert info is not None
|
||||
assert isinstance(info, WorktreeInfo)
|
||||
assert info.branch is not None
|
||||
|
||||
|
||||
class TestWorktreeRemoval:
|
||||
"""Tests for removing worktrees."""
|
||||
|
||||
@@ -194,17 +125,6 @@ class TestWorktreeRemoval:
|
||||
|
||||
assert not info.path.exists()
|
||||
|
||||
def test_remove_staging(self, temp_git_repo: Path):
|
||||
"""Can remove staging worktree."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
info = manager.get_or_create_staging("test-spec")
|
||||
|
||||
manager.remove_staging()
|
||||
|
||||
assert not info.path.exists()
|
||||
assert manager.staging_exists() is False
|
||||
|
||||
def test_remove_with_delete_branch(self, temp_git_repo: Path):
|
||||
"""Removing worktree can also delete the branch."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
@@ -225,56 +145,6 @@ class TestWorktreeRemoval:
|
||||
class TestWorktreeCommitAndMerge:
|
||||
"""Tests for commit and merge operations."""
|
||||
|
||||
def test_commit_in_staging(self, temp_git_repo: Path):
|
||||
"""Can commit changes in staging worktree."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
info = manager.get_or_create_staging("test-spec")
|
||||
|
||||
# Make changes in staging
|
||||
(info.path / "new-file.txt").write_text("new content")
|
||||
|
||||
result = manager.commit_in_staging("Test commit")
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify commit was made
|
||||
log_result = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=info.path, capture_output=True, text=True
|
||||
)
|
||||
assert "Test commit" in log_result.stdout
|
||||
|
||||
def test_commit_in_staging_nothing_to_commit(self, temp_git_repo: Path):
|
||||
"""commit_in_staging succeeds when nothing to commit."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
manager.get_or_create_staging("test-spec")
|
||||
|
||||
# No changes made
|
||||
result = manager.commit_in_staging("Empty commit")
|
||||
|
||||
assert result is True # Should succeed (nothing to commit is OK)
|
||||
|
||||
def test_merge_staging_sync(self, temp_git_repo: Path):
|
||||
"""Can merge staging worktree to main branch."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
info = manager.get_or_create_staging("test-spec")
|
||||
|
||||
# Make changes in staging
|
||||
(info.path / "feature.txt").write_text("feature content")
|
||||
manager.commit_in_staging("Add feature")
|
||||
|
||||
# Merge back
|
||||
result = manager.merge_staging(delete_after=False)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify file is in main branch
|
||||
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
|
||||
assert (temp_git_repo / "feature.txt").exists()
|
||||
|
||||
def test_merge_worktree(self, temp_git_repo: Path):
|
||||
"""Can merge a worktree back to main."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
@@ -323,12 +193,16 @@ class TestChangeTracking:
|
||||
"""get_change_summary returns correct counts."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
info = manager.get_or_create_staging("test-spec")
|
||||
info = manager.create_worktree("test-spec")
|
||||
|
||||
# Make various changes
|
||||
(info.path / "new-file.txt").write_text("new")
|
||||
(info.path / "README.md").write_text("modified")
|
||||
manager.commit_in_staging("Changes")
|
||||
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Changes"],
|
||||
cwd=info.path, capture_output=True
|
||||
)
|
||||
|
||||
summary = manager.get_change_summary("test-spec")
|
||||
|
||||
@@ -339,11 +213,15 @@ class TestChangeTracking:
|
||||
"""get_changed_files returns list of changed files."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
info = manager.get_or_create_staging("test-spec")
|
||||
info = manager.create_worktree("test-spec")
|
||||
|
||||
# Make changes
|
||||
(info.path / "added.txt").write_text("new file")
|
||||
manager.commit_in_staging("Add file")
|
||||
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Add file"],
|
||||
cwd=info.path, capture_output=True
|
||||
)
|
||||
|
||||
files = manager.get_changed_files("test-spec")
|
||||
|
||||
@@ -393,7 +271,7 @@ class TestWorktreeUtilities:
|
||||
manager.setup()
|
||||
manager.create_worktree("spec-1")
|
||||
manager.create_worktree("spec-2")
|
||||
manager.get_or_create_staging("test-spec")
|
||||
manager.create_worktree("spec-3")
|
||||
|
||||
manager.cleanup_all()
|
||||
|
||||
@@ -418,7 +296,7 @@ class TestWorktreeUtilities:
|
||||
"""get_test_commands detects Python project commands."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
info = manager.get_or_create_staging("test-spec")
|
||||
info = manager.create_worktree("test-spec")
|
||||
|
||||
# Create requirements.txt
|
||||
(info.path / "requirements.txt").write_text("flask\n")
|
||||
@@ -431,11 +309,11 @@ class TestWorktreeUtilities:
|
||||
"""get_test_commands detects Node.js project commands."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
info = manager.get_or_create_staging("test-spec")
|
||||
info = manager.create_worktree("test-spec-node")
|
||||
|
||||
# Create package.json
|
||||
(info.path / "package.json").write_text('{"name": "test"}')
|
||||
|
||||
commands = manager.get_test_commands("test-spec")
|
||||
commands = manager.get_test_commands("test-spec-node")
|
||||
|
||||
assert any("npm" in cmd for cmd in commands)
|
||||
|
||||
Reference in New Issue
Block a user