merge orcehestrator logic
This commit is contained in:
@@ -41,6 +41,12 @@ export default defineConfig({
|
||||
'@': resolve(__dirname, 'src/renderer'),
|
||||
'@shared': resolve(__dirname, 'src/shared')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
// Ignore worktrees directory to prevent HMR conflicts during merge operations
|
||||
ignored: ['**/.worktrees/**', '**/node_modules/**', '**/.git/**']
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1810,8 +1810,10 @@ export function registerTaskHandlers(
|
||||
totalFiles: 0,
|
||||
conflictFiles: 0,
|
||||
totalConflicts: 0,
|
||||
autoMergeable: 0
|
||||
}
|
||||
autoMergeable: 0,
|
||||
hasGitConflicts: false
|
||||
},
|
||||
gitConflicts: result.gitConflicts || null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '../ui/alert-dialog';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { Task, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats } from '../../../shared/types';
|
||||
import type { Task, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo } from '../../../shared/types';
|
||||
|
||||
interface TaskReviewProps {
|
||||
task: Task;
|
||||
@@ -46,7 +46,7 @@ interface TaskReviewProps {
|
||||
stageOnly: boolean;
|
||||
stagedSuccess: string | null;
|
||||
stagedProjectPath: string | undefined;
|
||||
mergePreview: { files: string[]; conflicts: MergeConflict[]; summary: MergeStats } | null;
|
||||
mergePreview: { files: string[]; conflicts: MergeConflict[]; summary: MergeStats; gitConflicts?: GitConflictInfo } | null;
|
||||
isLoadingPreview: boolean;
|
||||
showConflictDialog: boolean;
|
||||
onFeedbackChange: (value: string) => void;
|
||||
@@ -270,15 +270,22 @@ export function TaskReview({
|
||||
{mergePreview && (
|
||||
<div className={cn(
|
||||
"rounded-lg p-3 mb-3 border",
|
||||
mergePreview.conflicts.length === 0
|
||||
? "bg-success/10 border-success/30"
|
||||
: mergePreview.conflicts.some(c => c.severity === 'high' || c.severity === 'critical')
|
||||
? "bg-destructive/10 border-destructive/30"
|
||||
: "bg-warning/10 border-warning/30"
|
||||
mergePreview.gitConflicts?.hasConflicts
|
||||
? "bg-warning/10 border-warning/30" // AI will resolve - show warning not error
|
||||
: mergePreview.conflicts.length === 0
|
||||
? "bg-success/10 border-success/30"
|
||||
: mergePreview.conflicts.some(c => c.severity === 'high' || c.severity === 'critical')
|
||||
? "bg-destructive/10 border-destructive/30"
|
||||
: "bg-warning/10 border-warning/30"
|
||||
)}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium flex items-center gap-2">
|
||||
{mergePreview.conflicts.length === 0 ? (
|
||||
{mergePreview.gitConflicts?.hasConflicts ? (
|
||||
<>
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
Branch Diverged - AI Will Resolve
|
||||
</>
|
||||
) : mergePreview.conflicts.length === 0 ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
No Conflicts Detected
|
||||
@@ -301,9 +308,29 @@ export function TaskReview({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Show git conflict details if present */}
|
||||
{mergePreview.gitConflicts?.hasConflicts && (
|
||||
<div className="mb-3 p-2 bg-warning/10 rounded text-xs border border-warning/30">
|
||||
<p className="font-medium text-warning mb-1">Branch has diverged - AI will resolve</p>
|
||||
<p className="text-muted-foreground mb-2">
|
||||
The main branch has {mergePreview.gitConflicts.commitsBehind} new commit{mergePreview.gitConflicts.commitsBehind !== 1 ? 's' : ''} since this worktree was created.
|
||||
{mergePreview.gitConflicts.conflictingFiles.length} file{mergePreview.gitConflicts.conflictingFiles.length !== 1 ? 's' : ''} will need intelligent merging:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground">
|
||||
{mergePreview.gitConflicts.conflictingFiles.map((file, idx) => (
|
||||
<li key={idx} className="truncate">{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
AI will automatically merge these conflicts when you click Stage Changes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||||
<div>Files to merge: {mergePreview.summary.totalFiles}</div>
|
||||
{mergePreview.conflicts.length > 0 ? (
|
||||
{mergePreview.gitConflicts?.hasConflicts ? (
|
||||
<div className="text-warning">AI will resolve conflicts</div>
|
||||
) : mergePreview.conflicts.length > 0 ? (
|
||||
<>
|
||||
<div>Auto-mergeable: {mergePreview.summary.autoMergeable}</div>
|
||||
{mergePreview.summary.aiResolved !== undefined && (
|
||||
@@ -334,15 +361,23 @@ export function TaskReview({
|
||||
{/* Primary Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="success"
|
||||
variant={mergePreview?.gitConflicts?.hasConflicts ? "warning" : "success"}
|
||||
onClick={onMerge}
|
||||
disabled={isMerging || isDiscarding}
|
||||
className="flex-1"
|
||||
title={mergePreview?.gitConflicts?.hasConflicts ? "AI will resolve conflicts automatically" : undefined}
|
||||
>
|
||||
{isMerging ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{stageOnly ? 'Staging...' : 'Merging...'}
|
||||
{mergePreview?.gitConflicts?.hasConflicts
|
||||
? 'AI Resolving Conflicts...'
|
||||
: stageOnly ? 'Staging...' : 'Merging...'}
|
||||
</>
|
||||
) : mergePreview?.gitConflicts?.hasConflicts ? (
|
||||
<>
|
||||
<GitMerge className="mr-2 h-4 w-4" />
|
||||
{stageOnly ? 'Stage with AI Merge' : 'Merge with AI'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { checkTaskRunning, isIncompleteHumanReview, getTaskProgress } from '../../../stores/task-store';
|
||||
import type { Task, TaskLogs, TaskLogPhase, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats } from '../../../../shared/types';
|
||||
import type { Task, TaskLogs, TaskLogPhase, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
|
||||
|
||||
export interface UseTaskDetailOptions {
|
||||
task: Task;
|
||||
@@ -41,6 +41,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
files: string[];
|
||||
conflicts: MergeConflict[];
|
||||
summary: MergeStats;
|
||||
gitConflicts?: GitConflictInfo;
|
||||
} | null>(null);
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
||||
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
||||
|
||||
@@ -274,6 +274,9 @@ export interface WorktreeDiffFile {
|
||||
// Conflict severity levels from merge system
|
||||
export type ConflictSeverity = 'none' | 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
// Type of conflict
|
||||
export type ConflictType = 'semantic' | 'git';
|
||||
|
||||
// Information about a detected conflict
|
||||
export interface MergeConflict {
|
||||
file: string;
|
||||
@@ -283,6 +286,17 @@ export interface MergeConflict {
|
||||
canAutoMerge: boolean;
|
||||
strategy?: string;
|
||||
reason: string;
|
||||
type?: ConflictType; // 'semantic' = parallel task conflict, 'git' = branch divergence
|
||||
}
|
||||
|
||||
// Git-level conflict information (branch divergence)
|
||||
export interface GitConflictInfo {
|
||||
hasConflicts: boolean;
|
||||
conflictingFiles: string[];
|
||||
needsRebase: boolean;
|
||||
commitsBehind: number;
|
||||
baseBranch: string;
|
||||
specBranch: string;
|
||||
}
|
||||
|
||||
// Summary statistics from merge preview/execution
|
||||
@@ -293,6 +307,7 @@ export interface MergeStats {
|
||||
autoMergeable: number;
|
||||
aiResolved?: number;
|
||||
humanRequired?: number;
|
||||
hasGitConflicts?: boolean; // True if there are git-level conflicts requiring rebase
|
||||
}
|
||||
|
||||
export interface WorktreeMergeResult {
|
||||
@@ -304,11 +319,13 @@ export interface WorktreeMergeResult {
|
||||
// New conflict info from smart merge
|
||||
conflicts?: MergeConflict[];
|
||||
stats?: MergeStats;
|
||||
gitConflicts?: GitConflictInfo; // Git-level conflict info
|
||||
// Preview mode results
|
||||
preview?: {
|
||||
files: string[];
|
||||
conflicts: MergeConflict[];
|
||||
summary: MergeStats;
|
||||
gitConflicts?: GitConflictInfo;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,149 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
||||
cleanup_all_worktrees(project_dir, confirm=True)
|
||||
|
||||
|
||||
def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
"""
|
||||
Check for git-level merge conflicts by attempting a dry-run merge.
|
||||
|
||||
This detects conflicts that occur when main has diverged from the
|
||||
worktree branch (e.g., other changes were merged to main since
|
||||
the worktree was created).
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
|
||||
Returns:
|
||||
Dictionary with git conflict information:
|
||||
- has_conflicts: bool
|
||||
- conflicting_files: list of file paths
|
||||
- needs_rebase: bool (if main has advanced)
|
||||
- base_branch: str
|
||||
- spec_branch: str
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
debug(MODULE, "Checking for git-level merge conflicts...")
|
||||
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
result = {
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": False,
|
||||
"base_branch": "main",
|
||||
"spec_branch": spec_branch,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Get the current branch (base branch)
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
|
||||
# Check how many commits main is ahead of the merge-base
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", result["base_branch"], spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if merge_base_result.returncode == 0:
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Count commits main is ahead
|
||||
ahead_result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ahead_result.returncode == 0:
|
||||
commits_behind = int(ahead_result.stdout.strip())
|
||||
result["commits_behind"] = commits_behind
|
||||
if commits_behind > 0:
|
||||
result["needs_rebase"] = True
|
||||
debug(MODULE, f"Main is {commits_behind} commits ahead of worktree base")
|
||||
|
||||
# Try a merge --no-commit --no-ff to see if there would be conflicts
|
||||
# First, save current state
|
||||
stash_result = subprocess.run(
|
||||
["git", "stash", "push", "-m", "merge-preview-check"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Attempt the merge (dry run style)
|
||||
merge_result = subprocess.run(
|
||||
["git", "merge", "--no-commit", "--no-ff", spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if merge_result.returncode != 0:
|
||||
result["has_conflicts"] = True
|
||||
debug(MODULE, "Git merge would have conflicts")
|
||||
|
||||
# Parse conflicting files from the output
|
||||
# Look for "CONFLICT (content): Merge conflict in <file>"
|
||||
import re
|
||||
for line in merge_result.stdout.split("\n") + merge_result.stderr.split("\n"):
|
||||
# Match patterns like "CONFLICT (content): Merge conflict in path/to/file"
|
||||
match = re.search(r"CONFLICT.*:\s*(?:Merge conflict in\s+)?(.+)", line)
|
||||
if match:
|
||||
file_path = match.group(1).strip()
|
||||
if file_path and file_path not in result["conflicting_files"]:
|
||||
result["conflicting_files"].append(file_path)
|
||||
|
||||
# Also check git status for unmerged files
|
||||
status_result = subprocess.run(
|
||||
["git", "diff", "--name-only", "--diff-filter=U"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if status_result.returncode == 0:
|
||||
for line in status_result.stdout.strip().split("\n"):
|
||||
if line and line not in result["conflicting_files"]:
|
||||
result["conflicting_files"].append(line)
|
||||
|
||||
debug(MODULE, f"Conflicting files: {result['conflicting_files']}")
|
||||
else:
|
||||
debug_success(MODULE, "Git merge would succeed without conflicts")
|
||||
|
||||
finally:
|
||||
# Always abort the merge and restore state
|
||||
subprocess.run(
|
||||
["git", "merge", "--abort"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Restore stash if we made one
|
||||
if "No local changes" not in stash_result.stdout:
|
||||
subprocess.run(
|
||||
["git", "stash", "pop"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Error checking git conflicts: {e}")
|
||||
import traceback
|
||||
debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc())
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
"""
|
||||
Handle the --merge-preview command.
|
||||
@@ -138,6 +281,10 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
actually performing the merge. This is used by the UI to show
|
||||
potential conflicts before the user clicks "Stage Changes".
|
||||
|
||||
This checks for TWO types of conflicts:
|
||||
1. Semantic conflicts: Multiple parallel tasks modifying the same code
|
||||
2. Git conflicts: Main branch has diverged from worktree branch
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
@@ -164,6 +311,7 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
"error": f"No existing build found for '{spec_name}'",
|
||||
"files": [],
|
||||
"conflicts": [],
|
||||
"gitConflicts": None,
|
||||
"summary": {
|
||||
"totalFiles": 0,
|
||||
"conflictFiles": 0,
|
||||
@@ -173,6 +321,9 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
}
|
||||
|
||||
try:
|
||||
# First, check for git-level conflicts (diverged branches)
|
||||
git_conflicts = _check_git_merge_conflicts(project_dir, spec_name)
|
||||
|
||||
debug(MODULE, "Initializing MergeOrchestrator for preview...")
|
||||
|
||||
# Initialize the orchestrator
|
||||
@@ -186,14 +337,14 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
debug(MODULE, f"Refreshing evolution data from worktree: {worktree_path}")
|
||||
orchestrator.evolution_tracker.refresh_from_git(spec_name, worktree_path)
|
||||
|
||||
# Get merge preview
|
||||
# Get merge preview (semantic conflicts between parallel tasks)
|
||||
debug(MODULE, "Generating merge preview...")
|
||||
preview = orchestrator.preview_merge([spec_name])
|
||||
|
||||
# Transform to UI-friendly format
|
||||
# Transform semantic conflicts to UI-friendly format
|
||||
conflicts = []
|
||||
for c in preview.get("conflicts", []):
|
||||
debug_verbose(MODULE, f"Processing conflict",
|
||||
debug_verbose(MODULE, f"Processing semantic conflict",
|
||||
file=c.get("file", ""),
|
||||
severity=c.get("severity", "unknown"))
|
||||
conflicts.append({
|
||||
@@ -204,25 +355,51 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
"canAutoMerge": c.get("can_auto_merge", False),
|
||||
"strategy": c.get("strategy"),
|
||||
"reason": c.get("reason", ""),
|
||||
"type": "semantic",
|
||||
})
|
||||
|
||||
# Add git conflicts to the list
|
||||
for file_path in git_conflicts.get("conflicting_files", []):
|
||||
conflicts.append({
|
||||
"file": file_path,
|
||||
"location": "file-level",
|
||||
"tasks": [spec_name, git_conflicts["base_branch"]],
|
||||
"severity": "high",
|
||||
"canAutoMerge": False,
|
||||
"strategy": None,
|
||||
"reason": f"File modified in both {git_conflicts['base_branch']} and worktree since branch point",
|
||||
"type": "git",
|
||||
})
|
||||
|
||||
summary = preview.get("summary", {})
|
||||
total_conflicts = summary.get("total_conflicts", 0) + len(git_conflicts.get("conflicting_files", []))
|
||||
conflict_files = summary.get("conflict_files", 0) + len(git_conflicts.get("conflicting_files", []))
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"files": preview.get("files_to_merge", []),
|
||||
"conflicts": conflicts,
|
||||
"gitConflicts": {
|
||||
"hasConflicts": git_conflicts["has_conflicts"],
|
||||
"conflictingFiles": git_conflicts["conflicting_files"],
|
||||
"needsRebase": git_conflicts["needs_rebase"],
|
||||
"commitsBehind": git_conflicts["commits_behind"],
|
||||
"baseBranch": git_conflicts["base_branch"],
|
||||
"specBranch": git_conflicts["spec_branch"],
|
||||
},
|
||||
"summary": {
|
||||
"totalFiles": summary.get("total_files", 0),
|
||||
"conflictFiles": summary.get("conflict_files", 0),
|
||||
"totalConflicts": summary.get("total_conflicts", 0),
|
||||
"conflictFiles": conflict_files,
|
||||
"totalConflicts": total_conflicts,
|
||||
"autoMergeable": summary.get("auto_mergeable", 0),
|
||||
"hasGitConflicts": git_conflicts["has_conflicts"],
|
||||
}
|
||||
}
|
||||
|
||||
debug_success(MODULE, "Merge preview complete",
|
||||
total_files=result["summary"]["totalFiles"],
|
||||
total_conflicts=result["summary"]["totalConflicts"],
|
||||
has_git_conflicts=git_conflicts["has_conflicts"],
|
||||
auto_mergeable=result["summary"]["autoMergeable"])
|
||||
|
||||
return result
|
||||
@@ -236,6 +413,7 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
"error": str(e),
|
||||
"files": [],
|
||||
"conflicts": [],
|
||||
"gitConflicts": None,
|
||||
"summary": {
|
||||
"totalFiles": 0,
|
||||
"conflictFiles": 0,
|
||||
|
||||
@@ -39,7 +39,7 @@ from .semantic_analyzer import SemanticAnalyzer
|
||||
from .conflict_detector import ConflictDetector
|
||||
from .auto_merger import AutoMerger
|
||||
from .file_evolution import FileEvolutionTracker
|
||||
from .ai_resolver import AIResolver
|
||||
from .ai_resolver import AIResolver, create_claude_resolver
|
||||
from .orchestrator import MergeOrchestrator
|
||||
|
||||
__all__ = [
|
||||
@@ -60,5 +60,6 @@ __all__ = [
|
||||
"AutoMerger",
|
||||
"FileEvolutionTracker",
|
||||
"AIResolver",
|
||||
"create_claude_resolver",
|
||||
"MergeOrchestrator",
|
||||
]
|
||||
|
||||
+1077
-9
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user