feat(merge,oauth): add path-aware AI merge resolution and device code streaming (#296)
* improve/merge-confclit-layer * improve AI resolution * fix caching on merge conflicts * imrpove merge layer with rebase * fix(github): add OAuth authentication to follow-up PR review The follow-up PR review AI analysis was failing with "Could not resolve authentication method" because AsyncAnthropic() was instantiated without credentials. The codebase uses OAuth tokens (not ANTHROPIC_API_KEY), so the client needs the auth_token parameter. Uses get_auth_token() from core.auth to retrieve the OAuth token from environment variables or macOS Keychain, matching how initial reviews authenticate via create_client(). * fix(merge): add validation to prevent AI writing natural language to files When AI merge receives truncated file contents (due to character limits), it sometimes responds with explanations like "I need to see the complete file contents..." instead of actual merged code. This garbage was being written directly to source files. Adds two validation layers after AI merge: 1. Natural language detection - catches patterns like "I need to", "Let me" 2. Syntax validation - uses esbuild to verify TypeScript/JavaScript syntax If either validation fails, the merge returns an error instead of writing invalid content to the file. Also adds project_dir field to ParallelMergeTask to enable syntax validation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(merge): skip git merge when AI already resolved path-mapped files When AI successfully merges path-mapped files (due to file renames between branches), the check only looked at `conflicts_resolved` which was 0 for path-mapped cases. This caused the code to fall through to `git merge` which then failed with conflicts. Now also checks `files_merged` and `ai_assisted` stats to determine if AI has already handled the merge. When files are AI-merged, they're already written and staged - no need for git merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix device code issue with github * fix(frontend): remember GitHub auth method (OAuth vs PAT) in settings Previously, after authenticating via GitHub OAuth, the settings page would show "Personal Access Token" input even though OAuth was used. This was confusing for users who expected to see their OAuth status. Added githubAuthMethod field to track how authentication was performed. Settings UI now shows "Authenticated via GitHub OAuth" when OAuth was used, with option to switch to manual token if needed. The auth method persists across settings reopening. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(backend): centralize OAuth client creation in core/client.py - Add create_message_client() for simple message API calls - Refactor followup_reviewer.py to use centralized client factory - Remove direct anthropic.AsyncAnthropic import from followup_reviewer - Add proper ValueError handling for missing OAuth token - Update docstrings to document both client factories This ensures all AI interactions use the centralized OAuth authentication in core/, avoiding direct ANTHROPIC_API_KEY usage per CLAUDE.md guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(frontend): remove unused statusColor variable in WorkspaceStatus Dead code cleanup - the statusColor variable was computed but never used in the component. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tests): add BrowserWindow mock to oauth-handlers tests The sendDeviceCodeToRenderer function uses BrowserWindow.getAllWindows() which wasn't mocked, causing unhandled rejection errors in tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(backend): use ClaudeSDKClient instead of raw anthropic SDK Remove direct anthropic SDK import from core/client.py and update followup_reviewer.py to use ClaudeSDKClient directly as per project conventions. All AI interactions should use claude-agent-sdk. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(backend): add debug logging for AI response in followup_reviewer Add logging to diagnose why AI review returns no JSON - helps identify if response is in thinking blocks vs text blocks. 🤖 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:
@@ -14,7 +14,14 @@ _PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from core.workspace.git_utils import _is_auto_claude_file, is_lock_file
|
||||
from core.workspace.git_utils import (
|
||||
_is_auto_claude_file,
|
||||
apply_path_mapping,
|
||||
detect_file_renames,
|
||||
get_file_content_from_ref,
|
||||
get_merge_base,
|
||||
is_lock_file,
|
||||
)
|
||||
from debug import debug_warning
|
||||
from ui import (
|
||||
Icons,
|
||||
@@ -680,6 +687,58 @@ def handle_merge_preview_command(
|
||||
# but we want to show the user all files that will be merged
|
||||
total_files_from_git = len(all_changed_files)
|
||||
|
||||
# Detect files that need AI merge due to path mappings (file renames)
|
||||
# This happens when the target branch has renamed/moved files that the
|
||||
# worktree modified at their old locations
|
||||
path_mapped_ai_merges: list[dict] = []
|
||||
path_mappings: dict[str, str] = {}
|
||||
|
||||
if git_conflicts["needs_rebase"] and git_conflicts["commits_behind"] > 0:
|
||||
# Get the merge-base between the branches
|
||||
spec_branch = git_conflicts["spec_branch"]
|
||||
base_branch = git_conflicts["base_branch"]
|
||||
merge_base = get_merge_base(project_dir, spec_branch, base_branch)
|
||||
|
||||
if merge_base:
|
||||
# Detect file renames between merge-base and current base branch
|
||||
path_mappings = detect_file_renames(
|
||||
project_dir, merge_base, base_branch
|
||||
)
|
||||
|
||||
if path_mappings:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected {len(path_mappings)} file rename(s) between merge-base and target",
|
||||
sample_mappings={
|
||||
k: v for k, v in list(path_mappings.items())[:3]
|
||||
},
|
||||
)
|
||||
|
||||
# Check which changed files have path mappings and need AI merge
|
||||
for file_path in all_changed_files:
|
||||
mapped_path = apply_path_mapping(file_path, path_mappings)
|
||||
if mapped_path != file_path:
|
||||
# File was renamed - check if both versions exist
|
||||
worktree_content = get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
target_content = get_file_content_from_ref(
|
||||
project_dir, base_branch, mapped_path
|
||||
)
|
||||
|
||||
if worktree_content and target_content:
|
||||
path_mapped_ai_merges.append(
|
||||
{
|
||||
"oldPath": file_path,
|
||||
"newPath": mapped_path,
|
||||
"reason": "File was renamed/moved and modified in both branches",
|
||||
}
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Path-mapped file needs AI merge: {file_path} -> {mapped_path}",
|
||||
)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
# Use git diff files as the authoritative list of files to merge
|
||||
@@ -693,6 +752,9 @@ def handle_merge_preview_command(
|
||||
"commitsBehind": git_conflicts["commits_behind"],
|
||||
"baseBranch": git_conflicts["base_branch"],
|
||||
"specBranch": git_conflicts["spec_branch"],
|
||||
# Path-mapped files that need AI merge due to renames
|
||||
"pathMappedAIMerges": path_mapped_ai_merges,
|
||||
"totalRenames": len(path_mappings),
|
||||
},
|
||||
"summary": {
|
||||
# Use git diff count, not semantic tracker count
|
||||
@@ -702,6 +764,8 @@ def handle_merge_preview_command(
|
||||
"autoMergeable": summary.get("auto_mergeable", 0),
|
||||
"hasGitConflicts": git_conflicts["has_conflicts"]
|
||||
and len(non_lock_conflicting_files) > 0,
|
||||
# Include path-mapped AI merge count for UI display
|
||||
"pathMappedAIMergeCount": len(path_mapped_ai_merges),
|
||||
},
|
||||
# Include lock files info so UI can optionally show them
|
||||
"lockFilesExcluded": lock_files_excluded,
|
||||
@@ -716,6 +780,8 @@ def handle_merge_preview_command(
|
||||
total_conflicts=result["summary"]["totalConflicts"],
|
||||
has_git_conflicts=git_conflicts["has_conflicts"],
|
||||
auto_mergeable=result["summary"]["autoMergeable"],
|
||||
path_mapped_ai_merges=len(path_mapped_ai_merges),
|
||||
total_renames=len(path_mappings),
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -736,5 +802,6 @@ def handle_merge_preview_command(
|
||||
"conflictFiles": 0,
|
||||
"totalConflicts": 0,
|
||||
"autoMergeable": 0,
|
||||
"pathMappedAIMergeCount": 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ Claude SDK Client Configuration
|
||||
===============================
|
||||
|
||||
Functions for creating and configuring the Claude Agent SDK client.
|
||||
|
||||
All AI interactions should use `create_client()` to ensure consistent OAuth authentication
|
||||
and proper tool/MCP configuration. For simple message calls without full agent sessions,
|
||||
use `ClaudeSDKClient` directly with `allowed_tools=[]` and `max_turns=1`.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
+236
-28
@@ -84,6 +84,12 @@ from core.workspace.git_utils import (
|
||||
_is_auto_claude_file,
|
||||
get_existing_build_worktree,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
apply_path_mapping as _apply_path_mapping,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
detect_file_renames as _detect_file_renames,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
get_changed_files_from_branch as _get_changed_files_from_branch,
|
||||
)
|
||||
@@ -93,6 +99,9 @@ from core.workspace.git_utils import (
|
||||
from core.workspace.git_utils import (
|
||||
is_lock_file as _is_lock_file,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
validate_merged_syntax as _validate_merged_syntax,
|
||||
)
|
||||
|
||||
# Import from refactored modules in core/workspace/
|
||||
from core.workspace.models import (
|
||||
@@ -230,12 +239,15 @@ def merge_existing_build(
|
||||
if smart_result is not None:
|
||||
# Smart merge handled it (success or identified conflicts)
|
||||
if smart_result.get("success"):
|
||||
# Check if smart merge resolved git conflicts directly
|
||||
# Check if smart merge resolved git conflicts or path-mapped files
|
||||
stats = smart_result.get("stats", {})
|
||||
had_conflicts = stats.get("conflicts_resolved", 0) > 0
|
||||
files_merged = stats.get("files_merged", 0) > 0
|
||||
ai_assisted = stats.get("ai_assisted", 0) > 0
|
||||
|
||||
if had_conflicts:
|
||||
# Git conflicts were resolved (via AI or lock file exclusion) - changes are already staged
|
||||
if had_conflicts or files_merged or ai_assisted:
|
||||
# Git conflicts were resolved OR path-mapped files were AI merged
|
||||
# Changes are already written and staged - no need for git merge
|
||||
_print_merge_success(
|
||||
no_commit, stats, spec_name=spec_name, keep_worktree=True
|
||||
)
|
||||
@@ -246,7 +258,7 @@ def merge_existing_build(
|
||||
|
||||
return True
|
||||
else:
|
||||
# No git conflicts, do standard git merge
|
||||
# No conflicts and no files merged - do standard git merge
|
||||
success_result = manager.merge_worktree(
|
||||
spec_name, delete_after=False, no_commit=no_commit
|
||||
)
|
||||
@@ -731,6 +743,23 @@ def _resolve_git_conflicts_with_ai(
|
||||
merge_base=merge_base[:12] if merge_base else None,
|
||||
)
|
||||
|
||||
# Detect file renames between merge-base and target branch
|
||||
# This handles cases where files were moved/renamed (e.g., directory restructures)
|
||||
path_mappings: dict[str, str] = {}
|
||||
if merge_base:
|
||||
path_mappings = _detect_file_renames(project_dir, merge_base, base_branch)
|
||||
if path_mappings:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected {len(path_mappings)} file renames between merge-base and target",
|
||||
sample_mappings=dict(list(path_mappings.items())[:5]),
|
||||
)
|
||||
print(
|
||||
muted(
|
||||
f" Detected {len(path_mappings)} file rename(s) since branch creation"
|
||||
)
|
||||
)
|
||||
|
||||
# FIX: Copy NEW files FIRST before resolving conflicts
|
||||
# This ensures dependencies exist before files that import them are written
|
||||
changed_files = _get_changed_files_from_branch(
|
||||
@@ -748,14 +777,24 @@ def _resolve_git_conflicts_with_ai(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path = project_dir / file_path
|
||||
# Apply path mapping - write to new location if file was renamed
|
||||
target_file_path = _apply_path_mapping(file_path, path_mappings)
|
||||
target_path = project_dir / target_file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", file_path], cwd=project_dir, capture_output=True
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(file_path)
|
||||
debug(MODULE, f"Copied new file: {file_path}")
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Copied new file with path mapping: {file_path} -> {target_file_path}",
|
||||
)
|
||||
else:
|
||||
debug(MODULE, f"Copied new file: {file_path}")
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Could not copy new file {file_path}: {e}")
|
||||
|
||||
@@ -769,20 +808,26 @@ def _resolve_git_conflicts_with_ai(
|
||||
debug(MODULE, "Categorizing conflicting files for parallel processing")
|
||||
|
||||
for file_path in conflicting_files:
|
||||
debug(MODULE, f"Categorizing conflicting file: {file_path}")
|
||||
# Apply path mapping to get the target path in the current branch
|
||||
target_file_path = _apply_path_mapping(file_path, path_mappings)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Categorizing conflicting file: {file_path}"
|
||||
+ (f" -> {target_file_path}" if target_file_path != file_path else ""),
|
||||
)
|
||||
|
||||
try:
|
||||
# Get content from main branch
|
||||
# Get content from main branch using MAPPED path (file may have been renamed)
|
||||
main_content = _get_file_content_from_ref(
|
||||
project_dir, base_branch, file_path
|
||||
project_dir, base_branch, target_file_path
|
||||
)
|
||||
|
||||
# Get content from worktree branch
|
||||
# Get content from worktree branch using ORIGINAL path
|
||||
worktree_content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
|
||||
# Get content from merge-base (common ancestor)
|
||||
# Get content from merge-base (common ancestor) using ORIGINAL path
|
||||
base_content = None
|
||||
if merge_base:
|
||||
base_content = _get_file_content_from_ref(
|
||||
@@ -795,38 +840,49 @@ def _resolve_git_conflicts_with_ai(
|
||||
|
||||
if main_content is None:
|
||||
# File only exists in worktree - it's a new file (no AI needed)
|
||||
simple_merges.append((file_path, worktree_content))
|
||||
# Write to target path (mapped if applicable)
|
||||
simple_merges.append((target_file_path, worktree_content))
|
||||
debug(MODULE, f" {file_path}: new file (no AI needed)")
|
||||
elif worktree_content is None:
|
||||
# File only exists in main - was deleted in worktree (no AI needed)
|
||||
simple_merges.append((file_path, None)) # None = delete
|
||||
simple_merges.append((target_file_path, None)) # None = delete
|
||||
debug(MODULE, f" {file_path}: deleted (no AI needed)")
|
||||
else:
|
||||
# File exists in both - check if it's a lock file
|
||||
if _is_lock_file(file_path):
|
||||
if _is_lock_file(target_file_path):
|
||||
# Lock files should be excluded from merge entirely
|
||||
# They must be regenerated after merge by running the package manager
|
||||
# (e.g., npm install, pnpm install, uv sync, cargo update)
|
||||
#
|
||||
# Strategy: Take main branch version and let user regenerate
|
||||
lock_files_excluded.append(file_path)
|
||||
simple_merges.append((file_path, main_content))
|
||||
lock_files_excluded.append(target_file_path)
|
||||
simple_merges.append((target_file_path, main_content))
|
||||
debug(
|
||||
MODULE,
|
||||
f" {file_path}: lock file (excluded - will use main version)",
|
||||
f" {target_file_path}: lock file (excluded - will use main version)",
|
||||
)
|
||||
else:
|
||||
# Regular file - needs AI merge
|
||||
# Store the TARGET path for writing, but track original for content retrieval
|
||||
files_needing_ai_merge.append(
|
||||
ParallelMergeTask(
|
||||
file_path=file_path,
|
||||
file_path=target_file_path, # Use target path for writing
|
||||
main_content=main_content,
|
||||
worktree_content=worktree_content,
|
||||
base_content=base_content,
|
||||
spec_name=spec_name,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
)
|
||||
debug(MODULE, f" {file_path}: needs AI merge")
|
||||
debug(
|
||||
MODULE,
|
||||
f" {file_path}: needs AI merge"
|
||||
+ (
|
||||
f" (will write to {target_file_path})"
|
||||
if target_file_path != file_path
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(error(f" ✗ Failed to categorize {file_path}: {e}"))
|
||||
@@ -946,29 +1002,140 @@ def _resolve_git_conflicts_with_ai(
|
||||
if f not in conflicting_files and s != "A" # Skip new files, already copied
|
||||
]
|
||||
|
||||
# Separate files that need AI merge (path-mapped) from simple copies
|
||||
path_mapped_files: list[ParallelMergeTask] = []
|
||||
simple_copy_files: list[
|
||||
tuple[str, str, str]
|
||||
] = [] # (file_path, target_path, status)
|
||||
|
||||
for file_path, status in non_conflicting:
|
||||
# Apply path mapping for renamed/moved files
|
||||
target_file_path = _apply_path_mapping(file_path, path_mappings)
|
||||
|
||||
if target_file_path != file_path and status != "D":
|
||||
# File was renamed/moved - needs AI merge to incorporate changes
|
||||
# Get content from worktree (old path) and target branch (new path)
|
||||
worktree_content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
target_content = _get_file_content_from_ref(
|
||||
project_dir, base_branch, target_file_path
|
||||
)
|
||||
base_content = None
|
||||
if merge_base:
|
||||
base_content = _get_file_content_from_ref(
|
||||
project_dir, merge_base, file_path
|
||||
)
|
||||
|
||||
if worktree_content and target_content:
|
||||
# Both exist - need AI merge
|
||||
path_mapped_files.append(
|
||||
ParallelMergeTask(
|
||||
file_path=target_file_path,
|
||||
main_content=target_content,
|
||||
worktree_content=worktree_content,
|
||||
base_content=base_content,
|
||||
spec_name=spec_name,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Path-mapped file needs AI merge: {file_path} -> {target_file_path}",
|
||||
)
|
||||
elif worktree_content:
|
||||
# Only exists in worktree - simple copy to new path
|
||||
simple_copy_files.append((file_path, target_file_path, status))
|
||||
else:
|
||||
# No path mapping or deletion - simple operation
|
||||
simple_copy_files.append((file_path, target_file_path, status))
|
||||
|
||||
# Process path-mapped files with AI merge
|
||||
if path_mapped_files:
|
||||
print()
|
||||
print_status(
|
||||
f"Merging {len(path_mapped_files)} path-mapped file(s) with AI...",
|
||||
"progress",
|
||||
)
|
||||
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run parallel merges for path-mapped files
|
||||
path_mapped_results = asyncio.run(
|
||||
_run_parallel_merges(
|
||||
tasks=path_mapped_files,
|
||||
project_dir=project_dir,
|
||||
max_concurrent=MAX_PARALLEL_AI_MERGES,
|
||||
)
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
for result in path_mapped_results:
|
||||
if result.success:
|
||||
target_path = project_dir / result.file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(result.merged_content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", result.file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(result.file_path)
|
||||
|
||||
if result.was_auto_merged:
|
||||
auto_merged_count += 1
|
||||
print(success(f" ✓ {result.file_path} (auto-merged)"))
|
||||
else:
|
||||
ai_merged_count += 1
|
||||
print(success(f" ✓ {result.file_path} (AI merged)"))
|
||||
else:
|
||||
print(error(f" ✗ {result.file_path}: {result.error}"))
|
||||
remaining_conflicts.append(
|
||||
{
|
||||
"file": result.file_path,
|
||||
"reason": result.error or "AI could not merge path-mapped file",
|
||||
"severity": "high",
|
||||
}
|
||||
)
|
||||
|
||||
print(muted(f" Path-mapped merge completed in {elapsed:.1f}s"))
|
||||
|
||||
# Process simple copy/delete files
|
||||
for file_path, target_file_path, status in simple_copy_files:
|
||||
try:
|
||||
if status == "D":
|
||||
# Deleted in worktree
|
||||
target_path = project_dir / file_path
|
||||
# Deleted in worktree - delete from target path
|
||||
target_path = project_dir / target_file_path
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
subprocess.run(
|
||||
["git", "add", file_path], cwd=project_dir, capture_output=True
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
# Added or modified - copy from worktree
|
||||
# Modified without path change - simple copy
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path = project_dir / file_path
|
||||
target_path = project_dir / target_file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", file_path], cwd=project_dir, capture_output=True
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(file_path)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Merged with path mapping: {file_path} -> {target_file_path}",
|
||||
)
|
||||
except Exception as e:
|
||||
print(muted(f" Warning: Could not process {file_path}: {e}"))
|
||||
|
||||
@@ -1274,6 +1441,47 @@ async def _merge_file_with_ai_async(
|
||||
# Strip any code fences the model might have added
|
||||
merged_content = _strip_code_fences(response_text.strip())
|
||||
|
||||
# VALIDATION: Check if AI returned natural language instead of code
|
||||
# This catches cases where AI says "I need to see more..." instead of merging
|
||||
natural_language_patterns = [
|
||||
"I need to",
|
||||
"Let me",
|
||||
"I cannot",
|
||||
"I'm unable",
|
||||
"The file appears",
|
||||
"I don't have",
|
||||
"Unfortunately",
|
||||
"I apologize",
|
||||
]
|
||||
first_line = merged_content.split("\n")[0] if merged_content else ""
|
||||
if any(pattern in first_line for pattern in natural_language_patterns):
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"AI returned natural language instead of code for {task.file_path}: {first_line[:100]}",
|
||||
)
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error=f"AI returned explanation instead of code: {first_line[:80]}...",
|
||||
)
|
||||
|
||||
# VALIDATION: Run syntax check on the merged content
|
||||
is_valid, syntax_error = _validate_merged_syntax(
|
||||
task.file_path, merged_content, task.project_dir
|
||||
)
|
||||
if not is_valid:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"AI merge produced invalid syntax for {task.file_path}: {syntax_error}",
|
||||
)
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error=f"AI merge produced invalid syntax: {syntax_error}",
|
||||
)
|
||||
|
||||
debug(MODULE, f"AI merged {task.file_path} successfully")
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
|
||||
@@ -83,6 +83,111 @@ MERGE_LOCK_TIMEOUT = 300 # 5 minutes
|
||||
MAX_SYNTAX_FIX_RETRIES = 2
|
||||
|
||||
|
||||
def detect_file_renames(
|
||||
project_dir: Path,
|
||||
from_ref: str,
|
||||
to_ref: str,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Detect file renames between two git refs using git's rename detection.
|
||||
|
||||
This analyzes the commit history between two refs to find all file
|
||||
renames/moves. Critical for merging changes from older branches that
|
||||
used a different directory structure.
|
||||
|
||||
Uses git's -M flag for rename detection with high similarity threshold.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
from_ref: Starting ref (e.g., merge-base commit or old branch)
|
||||
to_ref: Target ref (e.g., current branch HEAD)
|
||||
|
||||
Returns:
|
||||
Dict mapping old_path -> new_path for all renamed files
|
||||
"""
|
||||
renames: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
# Use git log with rename detection to find all renames between refs
|
||||
# -M flag enables rename detection
|
||||
# --diff-filter=R shows only renames
|
||||
# --name-status shows status and file names
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
"--name-status",
|
||||
"-M",
|
||||
"--diff-filter=R",
|
||||
"--format=", # No commit info, just file changes
|
||||
f"{from_ref}..{to_ref}",
|
||||
],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.startswith("R"):
|
||||
# Format: R100\told_path\tnew_path (tab-separated)
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3:
|
||||
old_path = parts[1]
|
||||
new_path = parts[2]
|
||||
renames[old_path] = new_path
|
||||
|
||||
except Exception:
|
||||
pass # Return empty dict on error
|
||||
|
||||
return renames
|
||||
|
||||
|
||||
def apply_path_mapping(file_path: str, mappings: dict[str, str]) -> str:
|
||||
"""
|
||||
Apply file path mappings to get the new path for a file.
|
||||
|
||||
Args:
|
||||
file_path: Original file path (from older branch)
|
||||
mappings: Dict of old_path -> new_path from detect_file_renames
|
||||
|
||||
Returns:
|
||||
Mapped new path if found, otherwise original path
|
||||
"""
|
||||
# Direct match
|
||||
if file_path in mappings:
|
||||
return mappings[file_path]
|
||||
|
||||
# No mapping found
|
||||
return file_path
|
||||
|
||||
|
||||
def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
|
||||
"""
|
||||
Get the merge-base commit between two refs.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
ref1: First ref (branch/commit)
|
||||
ref2: Second ref (branch/commit)
|
||||
|
||||
Returns:
|
||||
Merge-base commit hash, or None if not found
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", ref1, ref2],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def has_uncommitted_changes(project_dir: Path) -> bool:
|
||||
"""Check if user has unsaved work."""
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -36,6 +36,7 @@ class ParallelMergeTask:
|
||||
worktree_content: str
|
||||
base_content: str | None
|
||||
spec_name: str
|
||||
project_dir: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -543,10 +543,6 @@ class FollowupReviewer:
|
||||
Returns parsed AI response with finding resolutions and new findings,
|
||||
or None if AI review fails.
|
||||
"""
|
||||
# Use raw Anthropic client for simple message API calls
|
||||
# (ClaudeSDKClient is for agent sessions, not direct message calls)
|
||||
import anthropic
|
||||
|
||||
self._report_progress(
|
||||
"analyzing", 65, "Running AI-powered review...", context.pr_number
|
||||
)
|
||||
@@ -623,21 +619,51 @@ Please analyze this follow-up review context and provide your response in the JS
|
||||
"""
|
||||
|
||||
try:
|
||||
# Create Anthropic client for simple message API call
|
||||
# Note: For agent sessions with tools, use ClaudeSDKClient instead
|
||||
client = anthropic.AsyncAnthropic()
|
||||
# Use ClaudeSDKClient directly for simple message calls
|
||||
# (no agent tools needed, just a single query/response)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
model = self.config.model or "claude-sonnet-4-5-20250929"
|
||||
|
||||
response = await client.messages.create(
|
||||
model=model,
|
||||
max_tokens=4096,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt="You are a code review assistant. Analyze the provided context and respond with valid JSON.",
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
max_thinking_tokens=2048,
|
||||
)
|
||||
)
|
||||
|
||||
# Parse the response
|
||||
response_text = response.content[0].text
|
||||
response_text = ""
|
||||
async with client:
|
||||
await client.query(user_message)
|
||||
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
logger.debug(f"AI response message type: {msg_type}")
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
logger.debug(f" Content block type: {block_type}")
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
elif hasattr(block, "thinking"):
|
||||
# Skip thinking blocks - we only want the final text
|
||||
logger.debug(" (skipping thinking block)")
|
||||
|
||||
if not response_text:
|
||||
logger.warning("AI returned empty response (no text blocks found)")
|
||||
return None
|
||||
|
||||
logger.debug(f"AI response text (first 500 chars): {response_text[:500]}")
|
||||
return self._parse_ai_response(response_text)
|
||||
|
||||
except ValueError as e:
|
||||
# OAuth token not found
|
||||
logger.warning(f"No OAuth token available for AI review: {e}")
|
||||
print("AI review failed: No OAuth token found", flush=True)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"AI review failed: {e}")
|
||||
return None
|
||||
|
||||
@@ -44,11 +44,21 @@ vi.mock('electron', () => {
|
||||
}
|
||||
})();
|
||||
|
||||
// Mock BrowserWindow for sendDeviceCodeToRenderer
|
||||
const mockBrowserWindow = {
|
||||
getAllWindows: () => [{
|
||||
webContents: {
|
||||
send: vi.fn()
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
return {
|
||||
ipcMain: mockIpcMain,
|
||||
shell: {
|
||||
openExternal: (...args: unknown[]) => mockOpenExternal(...args)
|
||||
}
|
||||
},
|
||||
BrowserWindow: mockBrowserWindow
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,28 @@
|
||||
* Provides a simpler OAuth flow than manual PAT creation
|
||||
*/
|
||||
|
||||
import { ipcMain, shell } from 'electron';
|
||||
import { ipcMain, shell, BrowserWindow } from 'electron';
|
||||
import { execSync, execFileSync, spawn } from 'child_process';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult } from '../../../shared/types';
|
||||
import { getAugmentedEnv, findExecutable } from '../../env-utils';
|
||||
|
||||
/**
|
||||
* Send device code info to all renderer windows immediately when extracted
|
||||
* This allows the UI to display the code while the auth process is still running
|
||||
*/
|
||||
function sendDeviceCodeToRenderer(deviceCode: string, authUrl: string, browserOpened: boolean): void {
|
||||
debugLog('Sending device code to renderer windows');
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
win.webContents.send(IPC_CHANNELS.GITHUB_AUTH_DEVICE_CODE, {
|
||||
deviceCode,
|
||||
authUrl,
|
||||
browserOpened
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging helper
|
||||
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -265,6 +281,10 @@ export function registerStartGhAuth(): void {
|
||||
// Don't fail here - we'll return the device code so user can manually navigate
|
||||
}
|
||||
|
||||
// IMMEDIATELY send device code to renderer so user can see it while auth is in progress
|
||||
// This is critical - the frontend needs to display the code while the gh process is still running
|
||||
sendDeviceCodeToRenderer(extractedDeviceCode, extractedAuthUrl, browserOpenedSuccessfully);
|
||||
|
||||
// Extraction complete - mutex flag stays true to prevent re-extraction
|
||||
// The deviceCodeExtracted flag will prevent future attempts
|
||||
extractionInProgress = false;
|
||||
|
||||
@@ -155,6 +155,11 @@ export interface GitHubAPI {
|
||||
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
|
||||
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
|
||||
|
||||
// OAuth event listener - receives device code immediately when extracted
|
||||
onGitHubAuthDeviceCode: (
|
||||
callback: (data: { deviceCode: string; authUrl: string; browserOpened: boolean }) => void
|
||||
) => IpcListenerCleanup;
|
||||
|
||||
// Repository detection and management
|
||||
detectGitHubRepo: (projectPath: string) => Promise<IPCResult<string>>;
|
||||
getGitHubBranches: (repo: string, token: string) => Promise<IPCResult<string[]>>;
|
||||
@@ -398,6 +403,12 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
listGitHubUserRepos: (): Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_LIST_USER_REPOS),
|
||||
|
||||
// OAuth event listener - receives device code immediately when extracted (during auth process)
|
||||
onGitHubAuthDeviceCode: (
|
||||
callback: (data: { deviceCode: string; authUrl: string; browserOpened: boolean }) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.GITHUB_AUTH_DEVICE_CODE, callback),
|
||||
|
||||
// Repository detection and management
|
||||
detectGitHubRepo: (projectPath: string): Promise<IPCResult<string>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_DETECT_REPO, projectPath),
|
||||
|
||||
@@ -504,6 +504,7 @@ export function App() {
|
||||
githubToken: string;
|
||||
githubRepo: string;
|
||||
mainBranch: string;
|
||||
githubAuthMethod?: 'oauth' | 'pat';
|
||||
}) => {
|
||||
if (!gitHubSetupProject) return;
|
||||
|
||||
@@ -518,7 +519,8 @@ export function App() {
|
||||
await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, {
|
||||
githubEnabled: true,
|
||||
githubToken: settings.githubToken, // GitHub token for repo access
|
||||
githubRepo: settings.githubRepo
|
||||
githubRepo: settings.githubRepo,
|
||||
githubAuthMethod: settings.githubAuthMethod // Track how user authenticated
|
||||
});
|
||||
|
||||
// Update project settings with mainBranch
|
||||
|
||||
@@ -42,7 +42,7 @@ interface GitHubSetupModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: Project;
|
||||
onComplete: (settings: { githubToken: string; githubRepo: string; mainBranch: string }) => void;
|
||||
onComplete: (settings: { githubToken: string; githubRepo: string; mainBranch: string; githubAuthMethod?: 'oauth' | 'pat' }) => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
@@ -367,7 +367,8 @@ export function GitHubSetupModal({
|
||||
onComplete({
|
||||
githubToken,
|
||||
githubRepo,
|
||||
mainBranch: selectedBranch
|
||||
mainBranch: selectedBranch,
|
||||
githubAuthMethod: 'oauth' // Setup modal always uses OAuth flow
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+31
-5
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Github, RefreshCw, KeyRound, Info } from 'lucide-react';
|
||||
import { Github, RefreshCw, KeyRound, Info, CheckCircle2 } from 'lucide-react';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { PasswordInput } from './PasswordInput';
|
||||
@@ -31,17 +31,24 @@ export function GitHubIntegrationSection({
|
||||
isCheckingGitHub,
|
||||
projectName,
|
||||
}: GitHubIntegrationSectionProps) {
|
||||
const [showOAuthFlow, setShowOAuthFlow] = useState(false);
|
||||
// Show OAuth flow if user previously used OAuth, or if there's no token yet
|
||||
const [showOAuthFlow, setShowOAuthFlow] = useState(
|
||||
envConfig.githubAuthMethod === 'oauth' || (!envConfig.githubToken && !envConfig.githubAuthMethod)
|
||||
);
|
||||
|
||||
const badge = envConfig.githubEnabled ? (
|
||||
<StatusBadge status="success" label="Enabled" />
|
||||
) : null;
|
||||
|
||||
const handleOAuthSuccess = (token: string, _username?: string) => {
|
||||
onUpdateConfig({ githubToken: token });
|
||||
onUpdateConfig({ githubToken: token, githubAuthMethod: 'oauth' });
|
||||
setShowOAuthFlow(false);
|
||||
};
|
||||
|
||||
const handleManualTokenChange = (value: string) => {
|
||||
onUpdateConfig({ githubToken: value, githubAuthMethod: 'pat' });
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
title="GitHub Integration"
|
||||
@@ -81,7 +88,26 @@ export function GitHubIntegrationSection({
|
||||
|
||||
{envConfig.githubEnabled && (
|
||||
<>
|
||||
{showOAuthFlow ? (
|
||||
{/* Show OAuth connected state when authenticated via OAuth */}
|
||||
{envConfig.githubAuthMethod === 'oauth' && envConfig.githubToken && !showOAuthFlow ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-foreground">GitHub Authentication</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onUpdateConfig({ githubToken: '', githubAuthMethod: undefined })}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Use Manual Token
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg border border-success/30 bg-success/5">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
<span className="text-sm text-foreground">Authenticated via GitHub OAuth (gh CLI)</span>
|
||||
</div>
|
||||
</div>
|
||||
) : showOAuthFlow ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-foreground">GitHub Authentication</Label>
|
||||
@@ -125,7 +151,7 @@ export function GitHubIntegrationSection({
|
||||
</p>
|
||||
<PasswordInput
|
||||
value={envConfig.githubToken || ''}
|
||||
onChange={(value) => onUpdateConfig({ githubToken: value })}
|
||||
onChange={handleManualTokenChange}
|
||||
placeholder="ghp_xxxxxxxx or github_pat_xxxxxxxx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,38 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once on mount, checkGitHubStatus is intentionally excluded
|
||||
}, [clearAuthTimeout]);
|
||||
|
||||
// Listen for device code events from the main process
|
||||
// This allows us to display the code IMMEDIATELY when extracted, not after the auth completes
|
||||
useEffect(() => {
|
||||
if (status !== 'authenticating') {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Setting up device code event listener');
|
||||
|
||||
// Listen for device code from main process (sent immediately when extracted)
|
||||
const cleanup = window.electronAPI.onGitHubAuthDeviceCode((data) => {
|
||||
debugLog('Received device code from main process:', {
|
||||
hasCode: !!data.deviceCode,
|
||||
authUrl: data.authUrl,
|
||||
browserOpened: data.browserOpened
|
||||
});
|
||||
|
||||
if (data.deviceCode) {
|
||||
setDeviceCode(data.deviceCode);
|
||||
}
|
||||
if (data.authUrl) {
|
||||
setAuthUrl(data.authUrl);
|
||||
}
|
||||
setBrowserOpened(data.browserOpened);
|
||||
});
|
||||
|
||||
return () => {
|
||||
debugLog('Cleaning up device code event listener');
|
||||
cleanup();
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
const checkGitHubStatus = async () => {
|
||||
debugLog('checkGitHubStatus() called');
|
||||
setStatus('checking');
|
||||
|
||||
@@ -158,8 +158,8 @@ export function GitHubIntegration({
|
||||
debugLog('handleOAuthSuccess called with token length:', token.length);
|
||||
debugLog('OAuth username:', username);
|
||||
|
||||
// Update the token
|
||||
updateEnvConfig({ githubToken: token });
|
||||
// Update the token and auth method
|
||||
updateEnvConfig({ githubToken: token, githubAuthMethod: 'oauth' });
|
||||
|
||||
// Show success state with username
|
||||
setOauthUsername(username || null);
|
||||
|
||||
@@ -193,21 +193,14 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Restore merge preview from sessionStorage on mount (survives HMR reloads)
|
||||
// Clear merge preview cache when task changes to ensure fresh data is fetched
|
||||
// This invalidates any stale cached data (e.g., old uncommitted changes status)
|
||||
useEffect(() => {
|
||||
const storageKey = `mergePreview-${task.id}`;
|
||||
const stored = sessionStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
try {
|
||||
const previewData = JSON.parse(stored);
|
||||
console.warn('%c[useTaskDetail] Restored merge preview from sessionStorage:', 'color: magenta;', previewData);
|
||||
setMergePreview(previewData);
|
||||
// Don't auto-popup - restored data stays silent
|
||||
} catch {
|
||||
console.warn('[useTaskDetail] Failed to parse stored merge preview');
|
||||
sessionStorage.removeItem(storageKey);
|
||||
}
|
||||
}
|
||||
// Clear any existing cached preview - we want fresh data when opening a task
|
||||
sessionStorage.removeItem(storageKey);
|
||||
setMergePreview(null);
|
||||
console.warn('[useTaskDetail] Cleared merge preview cache for task:', task.id);
|
||||
}, [task.id]);
|
||||
|
||||
// Load merge preview (conflict detection)
|
||||
|
||||
@@ -62,14 +62,21 @@ export function WorkspaceStatus({
|
||||
const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0;
|
||||
const hasAIConflicts = mergePreview && mergePreview.conflicts.length > 0;
|
||||
|
||||
// Determine overall status
|
||||
const statusColor = hasGitConflicts
|
||||
? 'warning'
|
||||
: hasUncommittedChanges
|
||||
? 'warning'
|
||||
: mergePreview && !hasAIConflicts
|
||||
? 'success'
|
||||
: 'info';
|
||||
// Check if branch needs rebase (main has advanced since spec was created)
|
||||
// This requires AI merge even if no explicit file conflicts are detected
|
||||
const needsRebase = mergePreview?.gitConflicts?.needsRebase;
|
||||
const commitsBehind = mergePreview?.gitConflicts?.commitsBehind || 0;
|
||||
|
||||
// Path-mapped files that need AI merge due to file renames
|
||||
const pathMappedAIMergeCount = mergePreview?.summary?.pathMappedAIMergeCount || 0;
|
||||
const totalRenames = mergePreview?.gitConflicts?.totalRenames || 0;
|
||||
|
||||
// Branch is behind if needsRebase is true and there are commits to catch up on
|
||||
// This triggers AI merge for path-mapped files even without explicit conflicts
|
||||
const isBranchBehind = needsRebase && commitsBehind > 0;
|
||||
|
||||
// Has path-mapped files that need AI merge
|
||||
const hasPathMappedMerges = pathMappedAIMergeCount > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
@@ -201,7 +208,7 @@ export function WorkspaceStatus({
|
||||
{mergePreview && (
|
||||
<div className={cn(
|
||||
"flex items-center justify-between p-2.5 rounded-lg border",
|
||||
hasGitConflicts
|
||||
hasGitConflicts || isBranchBehind || hasPathMappedMerges
|
||||
? "bg-warning/10 border-warning/20"
|
||||
: !hasAIConflicts
|
||||
? "bg-success/10 border-success/20"
|
||||
@@ -216,6 +223,18 @@ export function WorkspaceStatus({
|
||||
<span className="text-xs text-muted-foreground ml-2">AI will resolve</span>
|
||||
</div>
|
||||
</>
|
||||
) : isBranchBehind || hasPathMappedMerges ? (
|
||||
<>
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-warning">
|
||||
{hasPathMappedMerges ? 'Files Renamed' : 'Branch Behind'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
AI will resolve ({hasPathMappedMerges ? `${pathMappedAIMergeCount} files` : `${commitsBehind} commits`})
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : !hasAIConflicts ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
@@ -234,7 +253,7 @@ export function WorkspaceStatus({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(hasGitConflicts || hasAIConflicts) && (
|
||||
{(hasGitConflicts || isBranchBehind || hasPathMappedMerges || hasAIConflicts) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -273,6 +292,22 @@ export function WorkspaceStatus({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch Behind Details (no explicit conflicts but needs AI merge due to path mappings) */}
|
||||
{!hasGitConflicts && isBranchBehind && mergePreview?.gitConflicts && (
|
||||
<div className="text-xs text-muted-foreground pl-6">
|
||||
Target branch has {commitsBehind} new commit{commitsBehind !== 1 ? 's' : ''} since this build started.
|
||||
{hasPathMappedMerges ? (
|
||||
<span className="text-warning">
|
||||
{' '}{pathMappedAIMergeCount} file{pathMappedAIMergeCount !== 1 ? 's' : ''} need AI merge due to {totalRenames} file rename{totalRenames !== 1 ? 's' : ''}.
|
||||
</span>
|
||||
) : totalRenames > 0 ? (
|
||||
<span className="text-warning"> {totalRenames} file rename{totalRenames !== 1 ? 's' : ''} detected - AI will handle the merge.</span>
|
||||
) : (
|
||||
<span className="text-warning"> Files may have been renamed or moved - AI will handle the merge.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions Footer */}
|
||||
@@ -293,7 +328,7 @@ export function WorkspaceStatus({
|
||||
{/* Primary Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={hasGitConflicts ? "warning" : "success"}
|
||||
variant={hasGitConflicts || isBranchBehind || hasPathMappedMerges ? "warning" : "success"}
|
||||
onClick={onMerge}
|
||||
disabled={isMerging || isDiscarding}
|
||||
className="flex-1"
|
||||
@@ -301,12 +336,12 @@ export function WorkspaceStatus({
|
||||
{isMerging ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{hasGitConflicts ? 'Resolving...' : stageOnly ? 'Staging...' : 'Merging...'}
|
||||
{hasGitConflicts || isBranchBehind || hasPathMappedMerges ? 'Resolving...' : stageOnly ? 'Staging...' : 'Merging...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="mr-2 h-4 w-4" />
|
||||
{hasGitConflicts
|
||||
{hasGitConflicts || isBranchBehind || hasPathMappedMerges
|
||||
? (stageOnly ? 'Stage with AI Merge' : 'Merge with AI')
|
||||
: (stageOnly ? 'Stage Changes' : 'Merge to Main')}
|
||||
</>
|
||||
|
||||
@@ -132,6 +132,7 @@ const browserMockAPI: ElectronAPI = {
|
||||
createGitHubRepo: async () => ({ success: true, data: { fullName: '', url: '' } }),
|
||||
addGitRemote: async () => ({ success: true, data: { remoteUrl: '' } }),
|
||||
listGitHubOrgs: async () => ({ success: true, data: { orgs: [] } }),
|
||||
onGitHubAuthDeviceCode: () => () => {},
|
||||
onGitHubInvestigationProgress: () => () => {},
|
||||
onGitHubInvestigationComplete: () => () => {},
|
||||
onGitHubInvestigationError: () => () => {},
|
||||
|
||||
@@ -208,5 +208,8 @@ export const integrationMock = {
|
||||
{ login: 'another-org', avatarUrl: 'https://avatars.githubusercontent.com/u/2?v=4' }
|
||||
]
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
// OAuth device code event listener (for streaming device code during auth)
|
||||
onGitHubAuthDeviceCode: () => () => {}
|
||||
};
|
||||
|
||||
@@ -200,6 +200,9 @@ export const IPC_CHANNELS = {
|
||||
GITHUB_ADD_REMOTE: 'github:addRemote',
|
||||
GITHUB_LIST_ORGS: 'github:listOrgs',
|
||||
|
||||
// GitHub OAuth events (main -> renderer) - for streaming device code during auth
|
||||
GITHUB_AUTH_DEVICE_CODE: 'github:authDeviceCode',
|
||||
|
||||
// GitHub events (main -> renderer)
|
||||
GITHUB_INVESTIGATION_PROGRESS: 'github:investigationProgress',
|
||||
GITHUB_INVESTIGATION_COMPLETE: 'github:investigationComplete',
|
||||
|
||||
@@ -352,6 +352,11 @@ export interface ElectronAPI {
|
||||
) => Promise<IPCResult<{ remoteUrl: string }>>;
|
||||
listGitHubOrgs: () => Promise<IPCResult<{ orgs: Array<{ login: string; avatarUrl?: string }> }>>;
|
||||
|
||||
// GitHub OAuth device code event (streams device code during auth flow)
|
||||
onGitHubAuthDeviceCode: (
|
||||
callback: (data: { deviceCode: string; authUrl: string; browserOpened: boolean }) => void
|
||||
) => () => void;
|
||||
|
||||
// GitHub event listeners
|
||||
onGitHubInvestigationProgress: (
|
||||
callback: (projectId: string, status: GitHubInvestigationStatus) => void
|
||||
|
||||
@@ -288,6 +288,7 @@ export interface ProjectEnvConfig {
|
||||
githubToken?: string;
|
||||
githubRepo?: string; // Format: owner/repo
|
||||
githubAutoSync?: boolean; // Auto-sync issues on project load
|
||||
githubAuthMethod?: 'oauth' | 'pat'; // How the token was obtained
|
||||
|
||||
// Git/Worktree Settings
|
||||
defaultBranch?: string; // Base branch for worktree creation (e.g., 'main', 'develop')
|
||||
|
||||
@@ -331,6 +331,13 @@ export interface MergeConflict {
|
||||
type?: ConflictType; // 'semantic' = parallel task conflict, 'git' = branch divergence
|
||||
}
|
||||
|
||||
// Path-mapped file that needs AI merge due to rename
|
||||
export interface PathMappedAIMerge {
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Git-level conflict information (branch divergence)
|
||||
export interface GitConflictInfo {
|
||||
hasConflicts: boolean;
|
||||
@@ -339,6 +346,10 @@ export interface GitConflictInfo {
|
||||
commitsBehind: number;
|
||||
baseBranch: string;
|
||||
specBranch: string;
|
||||
// Files that need AI merge due to path mappings (file renames)
|
||||
pathMappedAIMerges?: PathMappedAIMerge[];
|
||||
// Total number of file renames detected
|
||||
totalRenames?: number;
|
||||
}
|
||||
|
||||
// Summary statistics from merge preview/execution
|
||||
@@ -350,6 +361,8 @@ export interface MergeStats {
|
||||
aiResolved?: number;
|
||||
humanRequired?: number;
|
||||
hasGitConflicts?: boolean; // True if there are git-level conflicts requiring rebase
|
||||
// Count of files needing AI merge due to path mappings (file renames)
|
||||
pathMappedAIMergeCount?: number;
|
||||
}
|
||||
|
||||
export interface WorktreeMergeResult {
|
||||
|
||||
@@ -27,7 +27,7 @@ from core.workspace import _run_parallel_merges
|
||||
class TestParallelMergeDataclasses:
|
||||
"""Tests for parallel merge data structures."""
|
||||
|
||||
def test_parallel_merge_task_creation(self):
|
||||
def test_parallel_merge_task_creation(self, tmp_path):
|
||||
"""ParallelMergeTask can be created with required fields."""
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/App.tsx",
|
||||
@@ -35,6 +35,7 @@ class TestParallelMergeDataclasses:
|
||||
worktree_content="const main = 2;",
|
||||
base_content="const main = 0;",
|
||||
spec_name="001-test",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert task.file_path == "src/App.tsx"
|
||||
@@ -42,8 +43,9 @@ class TestParallelMergeDataclasses:
|
||||
assert task.worktree_content == "const main = 2;"
|
||||
assert task.base_content == "const main = 0;"
|
||||
assert task.spec_name == "001-test"
|
||||
assert task.project_dir == tmp_path
|
||||
|
||||
def test_parallel_merge_task_optional_base(self):
|
||||
def test_parallel_merge_task_optional_base(self, tmp_path):
|
||||
"""ParallelMergeTask works with None base_content."""
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/new-file.tsx",
|
||||
@@ -51,6 +53,7 @@ class TestParallelMergeDataclasses:
|
||||
worktree_content="// worktree version",
|
||||
base_content=None, # New file, no common ancestor
|
||||
spec_name="001-new-feature",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert task.base_content is None
|
||||
@@ -105,7 +108,7 @@ class TestParallelMergeRunner:
|
||||
results = asyncio.run(_run_parallel_merges([], tmp_path))
|
||||
assert results == []
|
||||
|
||||
def test_parallel_merge_task_with_data(self):
|
||||
def test_parallel_merge_task_with_data(self, tmp_path):
|
||||
"""ParallelMergeTask holds merge data correctly."""
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/test.py",
|
||||
@@ -113,6 +116,7 @@ class TestParallelMergeRunner:
|
||||
worktree_content="def main():\n print('hi')",
|
||||
base_content="def main(): pass",
|
||||
spec_name="001-feature",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert "main" in task.main_content
|
||||
@@ -133,6 +137,7 @@ class TestSimple3WayMerge:
|
||||
worktree_content="def main(): pass", # Same as main
|
||||
base_content="def main(): pass", # Same as both
|
||||
spec_name="001-no-change",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
results = asyncio.run(_run_parallel_merges([task], tmp_path))
|
||||
@@ -151,6 +156,7 @@ class TestSimple3WayMerge:
|
||||
worktree_content="def main():\n print('new')", # Changed
|
||||
base_content="def main(): pass",
|
||||
spec_name="001-worktree-only",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
results = asyncio.run(_run_parallel_merges([task], tmp_path))
|
||||
@@ -169,6 +175,7 @@ class TestSimple3WayMerge:
|
||||
worktree_content="def main(): pass", # Same as base
|
||||
base_content="def main(): pass",
|
||||
spec_name="001-main-only",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
results = asyncio.run(_run_parallel_merges([task], tmp_path))
|
||||
@@ -187,6 +194,7 @@ class TestSimple3WayMerge:
|
||||
worktree_content="# Same content",
|
||||
base_content=None, # New file, no base
|
||||
spec_name="001-new-identical",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
results = asyncio.run(_run_parallel_merges([task], tmp_path))
|
||||
@@ -198,7 +206,7 @@ class TestSimple3WayMerge:
|
||||
class TestParallelMergeIntegration:
|
||||
"""Integration tests for parallel merge flow."""
|
||||
|
||||
def test_multiple_file_merge_structure(self):
|
||||
def test_multiple_file_merge_structure(self, tmp_path):
|
||||
"""Multiple ParallelMergeTasks can be created."""
|
||||
tasks = [
|
||||
ParallelMergeTask(
|
||||
@@ -207,6 +215,7 @@ class TestParallelMergeIntegration:
|
||||
worktree_content=f"# File {i} feature",
|
||||
base_content=f"# File {i} base",
|
||||
spec_name="001-multi",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user