diff --git a/auto-claude/workspace.py b/auto-claude/workspace.py index a90179e1..b3630e7a 100644 --- a/auto-claude/workspace.py +++ b/auto-claude/workspace.py @@ -82,11 +82,13 @@ from workspace.git_utils import ( get_changed_files_from_branch as _get_changed_files_from_branch, is_process_running as _is_process_running, is_binary_file as _is_binary_file, + is_lock_file as _is_lock_file, validate_merged_syntax as _validate_merged_syntax, create_conflict_file_with_git as _create_conflict_file_with_git, MAX_FILE_LINES_FOR_AI, MAX_PARALLEL_AI_MERGES, BINARY_EXTENSIONS, + LOCK_FILES, MERGE_LOCK_TIMEOUT, ) @@ -664,15 +666,22 @@ def _resolve_git_conflicts_with_ai( simple_merges.append((file_path, None)) # None = delete debug(MODULE, f" {file_path}: deleted (no AI needed)") else: - # File exists in both - needs AI merge - files_needing_ai_merge.append(ParallelMergeTask( - file_path=file_path, - main_content=main_content, - worktree_content=worktree_content, - base_content=base_content, - spec_name=spec_name, - )) - debug(MODULE, f" {file_path}: needs AI merge") + # File exists in both - check if it's a lock file + if _is_lock_file(file_path): + # Lock files should never go through AI - just take worktree version + # User can run package manager install to regenerate if needed + simple_merges.append((file_path, worktree_content)) + debug(MODULE, f" {file_path}: lock file (taking worktree version)") + else: + # Regular file - needs AI merge + files_needing_ai_merge.append(ParallelMergeTask( + file_path=file_path, + main_content=main_content, + worktree_content=worktree_content, + base_content=base_content, + spec_name=spec_name, + )) + debug(MODULE, f" {file_path}: needs AI merge") except Exception as e: print(error(f" ✗ Failed to categorize {file_path}: {e}")) @@ -693,7 +702,11 @@ def _resolve_git_conflicts_with_ai( target_path.write_text(merged_content, encoding="utf-8") subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) resolved_files.append(file_path) - print(success(f" ✓ {file_path} (new file)")) + # Determine the type for display + if _is_lock_file(file_path): + print(success(f" ✓ {file_path} (lock file - took worktree version)")) + else: + print(success(f" ✓ {file_path} (new file)")) else: # Delete the file target_path = project_dir / file_path @@ -758,14 +771,8 @@ def _resolve_git_conflicts_with_ai( if remaining_conflicts: print(muted(f" Failed: {len(remaining_conflicts)}")) - if remaining_conflicts: - return { - "success": False, - "resolved_files": resolved_files, - "remaining_conflicts": remaining_conflicts, - } - - # All conflicts resolved - now merge remaining non-conflicting files + # ALWAYS process non-conflicting files, even if some conflicts failed + # This ensures we get as much of the build as possible # (New files were already copied at the start) print(muted(" Merging remaining files...")) @@ -799,18 +806,31 @@ def _resolve_git_conflicts_with_ai( if resolved_files: _record_merge_completion(project_dir, spec_name, resolved_files) - return { - "success": True, + # Build result - partial success if some files failed but we got others + result = { + "success": len(remaining_conflicts) == 0, "resolved_files": resolved_files, "stats": { "files_merged": len(resolved_files), - "conflicts_resolved": len(conflicting_files), + "conflicts_resolved": len(conflicting_files) - len(remaining_conflicts), "ai_assisted": ai_merged_count, "auto_merged": auto_merged_count, "parallel_ai_merges": len(files_needing_ai_merge), }, } + # Add remaining conflicts if any (for UI to show what needs manual attention) + if remaining_conflicts: + result["remaining_conflicts"] = remaining_conflicts + result["partial_success"] = len(resolved_files) > 0 + print() + print(warning(f" ⚠ {len(remaining_conflicts)} file(s) could not be auto-merged:")) + for conflict in remaining_conflicts: + print(muted(f" - {conflict['file']}: {conflict['reason']}")) + print(muted(" These files may need manual review.")) + + return result + def _get_file_content_from_ref(project_dir: Path, ref: str, file_path: str) -> Optional[str]: """Get file content from a git ref (branch, commit, etc.).""" diff --git a/auto-claude/workspace/__init__.py b/auto-claude/workspace/__init__.py index c708500e..df1eb1af 100644 --- a/auto-claude/workspace/__init__.py +++ b/auto-claude/workspace/__init__.py @@ -19,13 +19,14 @@ import importlib.util import sys from pathlib import Path -# Import merge_existing_build from workspace.py (which coexists with this package) +# Import merge functions from workspace.py (which coexists with this package) # We use importlib to explicitly load workspace.py since Python prefers the package _workspace_file = Path(__file__).parent.parent / "workspace.py" _spec = importlib.util.spec_from_file_location("workspace_module", _workspace_file) _workspace_module = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_workspace_module) merge_existing_build = _workspace_module.merge_existing_build +_run_parallel_merges = _workspace_module._run_parallel_merges # Models and Enums from .models import ( @@ -46,11 +47,13 @@ from .git_utils import ( get_changed_files_from_branch, is_process_running, is_binary_file, + is_lock_file, validate_merged_syntax, create_conflict_file_with_git, # Export private names for backward compatibility _is_process_running, _is_binary_file, + _is_lock_file, _validate_merged_syntax, _get_file_content_from_ref, _get_changed_files_from_branch, @@ -59,6 +62,7 @@ from .git_utils import ( MAX_FILE_LINES_FOR_AI, MAX_PARALLEL_AI_MERGES, BINARY_EXTENSIONS, + LOCK_FILES, MERGE_LOCK_TIMEOUT, ) diff --git a/auto-claude/workspace/git_utils.py b/auto-claude/workspace/git_utils.py index 67e97263..3a3c839f 100644 --- a/auto-claude/workspace/git_utils.py +++ b/auto-claude/workspace/git_utils.py @@ -15,6 +15,22 @@ from typing import Optional MAX_FILE_LINES_FOR_AI = 5000 # Skip AI for files larger than this MAX_PARALLEL_AI_MERGES = 5 # Limit concurrent AI merge operations +# Lock files that should NEVER go through AI merge +# These are auto-generated and should just take the worktree version +# then regenerate via package manager install +LOCK_FILES = { + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'bun.lockb', + 'Pipfile.lock', + 'poetry.lock', + 'Cargo.lock', + 'Gemfile.lock', + 'composer.lock', + 'go.sum', +} + BINARY_EXTENSIONS = { '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.bmp', '.svg', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', @@ -120,6 +136,16 @@ def is_binary_file(file_path: str) -> bool: return Path(file_path).suffix.lower() in BINARY_EXTENSIONS +def is_lock_file(file_path: str) -> bool: + """ + Check if a file is a package manager lock file. + + Lock files should never go through AI merge - they're auto-generated + and should just take the worktree version, then regenerate via install. + """ + return Path(file_path).name in LOCK_FILES + + def validate_merged_syntax(file_path: str, content: str, project_dir: Path) -> tuple[bool, str]: """ Validate the syntax of merged code. @@ -277,6 +303,7 @@ def create_conflict_file_with_git( # Export the _is_process_running function for backward compatibility _is_process_running = is_process_running _is_binary_file = is_binary_file +_is_lock_file = is_lock_file _validate_merged_syntax = validate_merged_syntax _get_file_content_from_ref = get_file_content_from_ref _get_changed_files_from_branch = get_changed_files_from_branch