diff --git a/apps/backend/init.py b/apps/backend/init.py index 5f1962b4..19cfda82 100644 --- a/apps/backend/init.py +++ b/apps/backend/init.py @@ -4,8 +4,15 @@ Auto Claude project initialization utilities. Handles first-time setup of .auto-claude directory and ensures proper gitignore configuration. """ +import logging +import os +import subprocess from pathlib import Path +from core.git_executable import get_git_executable + +logger = logging.getLogger(__name__) + # All entries that should be added to .gitignore for auto-claude projects AUTO_CLAUDE_GITIGNORE_ENTRIES = [ ".auto-claude/", @@ -76,7 +83,83 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b return True -def ensure_all_gitignore_entries(project_dir: Path) -> list[str]: +def _is_git_repo(project_dir: Path) -> bool: + """Check if the directory is a git repository.""" + try: + result = subprocess.run( + [get_git_executable(), "rev-parse", "--is-inside-work-tree"], + cwd=project_dir, + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, Exception) as e: + logger.debug("Git repo check failed: %s", e) + return False + + +def _commit_gitignore(project_dir: Path) -> bool: + """ + Commit .gitignore changes with a standard message. + + FIX (#1087): Auto-commit .gitignore changes to prevent merge failures. + Without this, merging tasks fails with "local changes would be overwritten". + + Args: + project_dir: The project root directory + + Returns: + True if commit succeeded, False otherwise + """ + if not _is_git_repo(project_dir): + return False + + try: + # Use LC_ALL=C to ensure English git output for reliable parsing + git_env = {**os.environ, "LC_ALL": "C"} + + # Stage .gitignore + result = subprocess.run( + [get_git_executable(), "add", ".gitignore"], + cwd=project_dir, + capture_output=True, + text=True, + timeout=30, + env=git_env, + ) + if result.returncode != 0: + return False + + # Commit with standard message - explicitly specify .gitignore to avoid + # committing other staged files the user may have + result = subprocess.run( + [ + get_git_executable(), + "commit", + ".gitignore", + "-m", + "chore: add auto-claude entries to .gitignore", + ], + cwd=project_dir, + capture_output=True, + text=True, + timeout=30, + env=git_env, + ) + # Return True even if commit "fails" due to nothing to commit + # Check both stdout and stderr as message location varies by git version + combined_output = result.stdout + result.stderr + return result.returncode == 0 or "nothing to commit" in combined_output + + except (subprocess.TimeoutExpired, Exception) as e: + logger.debug("Git commit failed: %s", e) + return False + + +def ensure_all_gitignore_entries( + project_dir: Path, auto_commit: bool = False +) -> list[str]: """ Ensure all auto-claude related entries exist in the project's .gitignore file. @@ -84,6 +167,7 @@ def ensure_all_gitignore_entries(project_dir: Path) -> list[str]: Args: project_dir: The project root directory + auto_commit: If True, automatically commit the .gitignore changes Returns: List of entries that were added (empty if all already existed) @@ -120,6 +204,16 @@ def ensure_all_gitignore_entries(project_dir: Path) -> list[str]: added_entries.append(entry) gitignore_path.write_text(content) + + # Auto-commit if requested and entries were added + if auto_commit and added_entries: + if not _commit_gitignore(project_dir): + logger.warning( + "Failed to auto-commit .gitignore changes in %s. " + "Manual commit may be required to avoid merge conflicts.", + project_dir, + ) + return added_entries @@ -143,16 +237,17 @@ def init_auto_claude_dir(project_dir: Path) -> tuple[Path, bool]: auto_claude_dir.mkdir(parents=True, exist_ok=True) # Ensure all auto-claude entries are in .gitignore (only on first creation) + # FIX (#1087): Auto-commit the changes to prevent merge failures gitignore_updated = False if dir_created: - added = ensure_all_gitignore_entries(project_dir) + added = ensure_all_gitignore_entries(project_dir, auto_commit=True) gitignore_updated = len(added) > 0 else: # Even if dir exists, check gitignore on first run # Use a marker file to track if we've already checked marker = auto_claude_dir / ".gitignore_checked" if not marker.exists(): - added = ensure_all_gitignore_entries(project_dir) + added = ensure_all_gitignore_entries(project_dir, auto_commit=True) gitignore_updated = len(added) > 0 marker.touch() @@ -185,6 +280,7 @@ def repair_gitignore(project_dir: Path) -> list[str]: or when gitignore entries were manually removed. Also resets the .gitignore_checked marker to allow future updates. + Changes are automatically committed if the project is a git repository. Args: project_dir: The project root directory @@ -200,8 +296,8 @@ def repair_gitignore(project_dir: Path) -> list[str]: if marker.exists(): marker.unlink() - # Add all missing entries - added = ensure_all_gitignore_entries(project_dir) + # Add all missing entries and auto-commit + added = ensure_all_gitignore_entries(project_dir, auto_commit=True) # Re-create the marker if auto_claude_dir.exists():