merge orcehestrator logic

This commit is contained in:
AndyMik90
2025-12-16 10:54:17 +01:00
parent d8ba532beb
commit e8b6669d92
8 changed files with 1337 additions and 29 deletions
+6
View File
@@ -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);
+17
View File
@@ -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;
};
}
+183 -5
View File
@@ -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,
+2 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff