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:
Andy
2026-01-03 19:38:15 +01:00
committed by GitHub
parent 7177c7994d
commit 13535f1bcf
59 changed files with 2161 additions and 408 deletions
+2 -2
View File
@@ -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")
+1 -1
View File
@@ -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 = (
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+18 -2
View File
@@ -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"))
+10 -4
View File
@@ -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
+1 -1
View File
@@ -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():
+16 -87
View File
@@ -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"
+1 -19
View File
@@ -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
+8 -1
View File
@@ -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
+83 -13
View File
@@ -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,
)
+218 -9
View File
@@ -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
+11
View File
@@ -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);
}
);
}
+3 -3
View File
@@ -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 });
+4 -9
View File
@@ -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);
+11 -3
View File
@@ -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 });
+76
View File
@@ -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[];
+19 -1
View File
@@ -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',
+7 -3
View File
@@ -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"
}
}
+9 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)