diff --git a/apps/backend/merge/file_evolution/evolution_queries.py b/apps/backend/merge/file_evolution/evolution_queries.py index 22c509e5..b8f23be5 100644 --- a/apps/backend/merge/file_evolution/evolution_queries.py +++ b/apps/backend/merge/file_evolution/evolution_queries.py @@ -102,7 +102,7 @@ class EvolutionQueries: modifications = [] for file_path, evolution in evolutions.items(): snapshot = evolution.get_task_snapshot(task_id) - if snapshot and snapshot.semantic_changes: + if snapshot and snapshot.has_modifications: modifications.append((file_path, snapshot)) return modifications @@ -125,7 +125,7 @@ class EvolutionQueries: for file_path, evolution in evolutions.items(): for snapshot in evolution.task_snapshots: - if snapshot.task_id in task_ids and snapshot.semantic_changes: + if snapshot.task_id in task_ids and snapshot.has_modifications: if file_path not in file_tasks: file_tasks[file_path] = [] file_tasks[file_path].append(snapshot.task_id) diff --git a/apps/backend/merge/merge_pipeline.py b/apps/backend/merge/merge_pipeline.py index 3f64b55f..8a2cacaa 100644 --- a/apps/backend/merge/merge_pipeline.py +++ b/apps/backend/merge/merge_pipeline.py @@ -75,6 +75,18 @@ class MergePipeline: # If only one task modified the file, no conflict possible if len(task_snapshots) == 1: snapshot = task_snapshots[0] + + # Check if file has modifications but semantic analysis returned empty + # This happens for: function body changes, unsupported file types (Rust, Go, etc.) + # In this case, signal that the caller should use the worktree version directly + if snapshot.has_modifications and not snapshot.semantic_changes: + return MergeResult( + decision=MergeDecision.DIRECT_COPY, + file_path=file_path, + merged_content=None, # Caller must read from worktree + explanation=f"File modified by {snapshot.task_id} but no semantic changes detected - use worktree version", + ) + merged = apply_single_task_changes(baseline_content, snapshot, file_path) return MergeResult( decision=MergeDecision.AUTO_MERGED, diff --git a/apps/backend/merge/orchestrator.py b/apps/backend/merge/orchestrator.py index d02fee22..bc481313 100644 --- a/apps/backend/merge/orchestrator.py +++ b/apps/backend/merge/orchestrator.py @@ -203,6 +203,58 @@ class MergeOrchestrator: ) return self._merge_pipeline + def _read_worktree_file_for_direct_copy( + self, + file_path: str, + worktree_path: Path | None, + ) -> tuple[str | None, bool]: + """ + Read file content from worktree for DIRECT_COPY merge. + + Args: + file_path: Relative path to the file + worktree_path: Path to the worktree directory + + Returns: + Tuple of (content, success). If success is False, content is None + and the caller should mark the merge as FAILED. + """ + if not worktree_path: + logger.warning( + f"DIRECT_COPY: No worktree path provided for file: {file_path}" + ) + debug_warning( + MODULE, + "DIRECT_COPY: No worktree path provided", + file=file_path, + ) + return None, False + + worktree_file = worktree_path / file_path + if not worktree_file.exists(): + logger.warning(f"DIRECT_COPY: Worktree file not found: {worktree_file}") + debug_warning( + MODULE, + "DIRECT_COPY: Worktree file not found", + file=str(worktree_file), + ) + return None, False + + try: + content = worktree_file.read_text(encoding="utf-8") + debug_detailed( + MODULE, + f"Read file from worktree for direct copy: {file_path}", + ) + return content, True + except UnicodeDecodeError: + content = worktree_file.read_text(encoding="utf-8", errors="replace") + debug_detailed( + MODULE, + f"Read file from worktree with encoding fallback: {file_path}", + ) + return content, True + def merge_task( self, task_id: str, @@ -275,6 +327,20 @@ class MergeOrchestrator: task_snapshots=[snapshot], target_branch=target_branch, ) + + # Handle DIRECT_COPY: read file directly from worktree + # This happens when file has modifications but semantic analysis + # couldn't parse the changes (body modifications, unsupported languages) + if result.decision == MergeDecision.DIRECT_COPY: + content, success = self._read_worktree_file_for_direct_copy( + file_path, worktree_path + ) + if success: + result.merged_content = content + else: + result.decision = MergeDecision.FAILED + result.error = "Worktree file not found for DIRECT_COPY" + report.file_results[file_path] = result self._update_stats(report.stats, result) debug_verbose( @@ -374,12 +440,41 @@ class MergeOrchestrator: task_snapshots=snapshots, target_branch=target_branch, ) + + # Handle DIRECT_COPY: read file directly from worktree + # For multi-task merges, use the first task's worktree that modified this file + if result.decision == MergeDecision.DIRECT_COPY: + # Find the worktree path from the first task that modified this file + worktree_path = None + for tid in modifying_tasks: + for req in requests: + if req.task_id == tid and req.worktree_path: + worktree_path = req.worktree_path + break + if worktree_path: + break + + content, success = self._read_worktree_file_for_direct_copy( + file_path, worktree_path + ) + if success: + result.merged_content = content + else: + result.decision = MergeDecision.FAILED + result.error = "Worktree file not found for DIRECT_COPY" + report.file_results[file_path] = result self._update_stats(report.stats, result) report.success = report.stats.files_failed == 0 except Exception as e: + debug_error( + MODULE, + "Merge failed for tasks", + task_ids=[r.task_id for r in requests], + error=str(e), + ) logger.exception("Merge failed") report.success = False report.error = str(e) @@ -589,7 +684,7 @@ class MergeOrchestrator: written = [] for file_path, result in report.file_results.items(): - if result.merged_content: + if result.merged_content is not None: out_path = output_dir / file_path out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(result.merged_content, encoding="utf-8") @@ -640,7 +735,7 @@ class MergeOrchestrator: ) stats.conflicts_auto_resolved += len(result.conflicts_resolved) - if result.decision == MergeDecision.AUTO_MERGED: + if result.decision in (MergeDecision.AUTO_MERGED, MergeDecision.DIRECT_COPY): stats.files_auto_merged += 1 elif result.decision == MergeDecision.AI_MERGED: stats.files_ai_merged += 1 diff --git a/apps/backend/merge/types.py b/apps/backend/merge/types.py index 9e11832f..d1ceafb7 100644 --- a/apps/backend/merge/types.py +++ b/apps/backend/merge/types.py @@ -133,6 +133,7 @@ class MergeDecision(Enum): AI_MERGED = "ai_merged" # AI resolved the conflict NEEDS_HUMAN_REVIEW = "needs_human_review" # Flagged for human FAILED = "failed" # Could not merge + DIRECT_COPY = "direct_copy" # Use worktree version directly (no semantic merge) @dataclass @@ -414,6 +415,34 @@ class TaskSnapshot: raw_diff=data.get("raw_diff"), ) + @property + def has_modifications(self) -> bool: + """ + Check if this snapshot represents actual file modifications. + + Returns True if the file was modified, using content hash comparison + as the source of truth. This handles cases where the semantic analyzer + couldn't detect changes (e.g., function body modifications, unsupported + file types like Rust) but the file was actually changed. + + Also returns True for newly created files (where content_hash_before + is empty but content_hash_after is set). + """ + # If we have semantic changes, the file was definitely modified + if self.semantic_changes: + return True + + # Handle new files: if before is empty but after has content, it's a new file + if not self.content_hash_before and self.content_hash_after: + return True + + # Fall back to content hash comparison for files where semantic + # analysis returned empty (body modifications, unsupported languages) + if self.content_hash_before and self.content_hash_after: + return self.content_hash_before != self.content_hash_after + + return False + @dataclass class FileEvolution: @@ -534,7 +563,11 @@ class MergeResult: @property def success(self) -> bool: """Check if merge was successful.""" - return self.decision in {MergeDecision.AUTO_MERGED, MergeDecision.AI_MERGED} + return self.decision in { + MergeDecision.AUTO_MERGED, + MergeDecision.AI_MERGED, + MergeDecision.DIRECT_COPY, + } @property def needs_human_review(self) -> bool: