# --- FILE: gitsync_tool/core/submodule_handler.py --- import os import datetime import shutil import time import stat from typing import Tuple, List, Optional # Import using absolute path from the package from gitutility.logging_setup import log_handler from ..commands.git_commands import GitCommands, GitCommandError class SubmoduleHandler: """ Handles high-level logic for Git submodule operations, orchestrating multiple GitCommands to perform complex workflows like syncing or removing submodules. """ def __init__(self, git_commands: GitCommands): """ Initializes the SubmoduleHandler. Args: git_commands: An instance for executing low-level Git commands. """ if not isinstance(git_commands, GitCommands): raise TypeError("SubmoduleHandler requires a GitCommands instance.") self.git_commands: GitCommands = git_commands log_handler.log_debug("SubmoduleHandler initialized.", func_name="__init__") def execute_add_submodule( self, repo_path: str, submodule_url: str, submodule_path: str ) -> Tuple[bool, str]: """ Adds a new submodule and commits the change, ensuring it tracks a specific branch. """ func_name = "execute_add_submodule" log_handler.log_info( f"Executing add submodule: URL='{submodule_url}', Path='{submodule_path}'", func_name=func_name ) try: # --- PUNTO CHIAVE --- # Specifica il branch da tracciare. # L'opzione -b in 'git submodule add' aggiunge automaticamente 'branch = ...' a .gitmodules branch_to_track = "master" # o "main" se il tuo branch di default è main self.git_commands.submodule_add( repo_path, submodule_url, submodule_path, branch=branch_to_track # Assicurati che questo parametro venga passato ) commit_message = f"Feat: Add submodule '{submodule_path}' tracking branch '{branch_to_track}'" log_handler.log_info(f"Committing submodule addition...", func_name=func_name) commit_made = self.git_commands.git_commit(repo_path, commit_message, stage_all_first=False) if not commit_made: raise GitCommandError("Submodule was added but failed to create a commit for it.") success_message = f"Submodule '{submodule_path}' added and committed successfully." log_handler.log_info(success_message, func_name=func_name) return True, success_message except (GitCommandError, ValueError) as e: log_handler.log_error(f"Failed to add submodule '{submodule_path}': {e}", func_name=func_name) return False, f"Failed to add submodule: {e}" def execute_sync_all_submodules(self, repo_path: str) -> Tuple[bool, str, bool]: """ Initializes, fetches latest, and updates all submodules. If any submodule commit pointer changes, it creates a commit in the parent repo. This method is designed to raise GitCommandError on failure to be caught by workers. Args: repo_path: The path to the parent repository. Returns: A tuple (success, message, commit_was_made). Raises: GitCommandError: If the submodule update command fails. """ func_name = "execute_sync_all_submodules" log_handler.log_info(f"Executing sync for all submodules in '{repo_path}'", func_name=func_name) # Non serve più il blocco try...except qui, lasciamo che l'eccezione si propaghi # Step 1: Get the state of submodules BEFORE the update log_handler.log_debug("Getting pre-sync submodule status...", func_name=func_name) status_before = {s["path"]: s["commit"] for s in self.git_commands.submodule_status(repo_path)} # Step 2: Perform the update. This call will RAISE GitCommandError on failure. log_handler.log_info("Running 'git submodule update --init --remote --merge'...", func_name=func_name) self.git_commands.submodule_update( working_directory=repo_path, init=True, remote=True, recursive=True, merge=True ) log_handler.log_info("Submodule update command finished successfully.", func_name=func_name) # Step 3: Get the state AFTER the update log_handler.log_debug("Getting post-sync submodule status...", func_name=func_name) status_after = {s["path"]: s["commit"] for s in self.git_commands.submodule_status(repo_path)} # Step 4: Compare states changed_submodules = [] for path, new_commit in status_after.items(): old_commit = status_before.get(path) if old_commit != new_commit: changed_submodules.append((path, old_commit, new_commit)) # Step 5: If there are changes, stage and commit if not changed_submodules: msg = "All submodules are already up-to-date." log_handler.log_info(msg, func_name=func_name) return True, msg, False log_handler.log_info(f"Found {len(changed_submodules)} updated submodules. Staging changes...", func_name=func_name) commit_body_lines = [] for path, old_commit, new_commit in changed_submodules: self.git_commands.add_file(repo_path, path) commit_body_lines.append(f"- Update {path} from {old_commit[:7]} to {new_commit[:7]}") commit_message = "Chore: Sync submodules\n\n" + "\n".join(commit_body_lines) log_handler.log_info(f"Committing submodule updates with message:\n{commit_message}", func_name=func_name) commit_made = self.git_commands.git_commit(repo_path, commit_message, stage_all_first=False) if commit_made: msg = f"Successfully synced {len(changed_submodules)} submodule(s) and created commit." log_handler.log_info(msg, func_name=func_name) return True, msg, True else: msg = "Submodules were updated, but the parent repo commit failed unexpectedly." log_handler.log_error(msg, func_name=func_name) # Questo è un caso strano, ma lo trattiamo come un successo parziale senza commit return True, msg, False def execute_init_missing_submodules(self, repo_path: str) -> Tuple[bool, str]: """ Initializes and clones any submodules that are registered but not yet cloned. Args: repo_path: The path to the parent repository. Returns: A tuple (success, message). """ func_name = "execute_init_missing_submodules" log_handler.log_info(f"Initializing missing submodules in '{repo_path}'", func_name=func_name) try: self.git_commands.submodule_update( working_directory=repo_path, init=True, remote=False, # No need to fetch remote here, just init recursive=True, merge=False ) msg = "Missing submodules initialized successfully." log_handler.log_info(msg, func_name=func_name) return True, msg except (GitCommandError, ValueError) as e: log_handler.log_error(f"Failed to initialize submodules: {e}", func_name=func_name) return False, f"Failed to initialize submodules: {e}" def execute_remove_submodule(self, repo_path: str, submodule_path: str) -> Tuple[bool, str]: """ Completely removes a submodule from the repository, including its internal Git directory in .git/modules. Args: repo_path: The path to the parent repository. submodule_path: The local path of the submodule to remove. Returns: A tuple (success, message). """ func_name = "execute_remove_submodule" log_handler.log_warning( f"Executing complete REMOVAL of submodule '{submodule_path}' from '{repo_path}'", func_name=func_name ) try: # Step 1: De-initialize the submodule. log_handler.log_debug(f"Step 1: De-initializing submodule '{submodule_path}'...", func_name=func_name) self.git_commands.submodule_deinit(repo_path, submodule_path) # Step 2: Remove the submodule from the index and .gitmodules file. log_handler.log_debug(f"Step 2: Running 'git rm' for submodule '{submodule_path}'...", func_name=func_name) self.git_commands.git_rm(repo_path, submodule_path) # The submodule's working directory is now removed by 'git rm'. # The changes to .gitmodules and the removed path are now staged. # --- NUOVO STEP 3: Rimuovi la cartella cache da .git/modules --- git_modules_path = os.path.join(repo_path, ".git", "modules", submodule_path) log_handler.log_debug(f"Step 3: Removing internal git directory '{git_modules_path}'...", func_name=func_name) def _remove_readonly(func, path, exc_info): exc_type, exc_value, _ = exc_info if issubclass(exc_type, PermissionError) or (hasattr(exc_value, 'winerror') and exc_value.winerror == 5): os.chmod(path, stat.S_IWRITE) func(path) else: raise exc_value if os.path.isdir(git_modules_path): shutil.rmtree(git_modules_path, onerror=_remove_readonly) log_handler.log_info(f"Successfully removed internal directory: {git_modules_path}", func_name=func_name) else: log_handler.log_info(f"Internal directory not found, skipping: {git_modules_path}", func_name=func_name) # --- FINE NUOVO STEP --- # Step 4: Commit the removal. commit_message = f"Refactor: Remove submodule '{submodule_path}'" log_handler.log_info(f"Step 4: Committing submodule removal...", func_name=func_name) commit_made = self.git_commands.git_commit(repo_path, commit_message, stage_all_first=False) if not commit_made: # If 'git rm' succeeded, a commit should be possible. log_handler.log_warning("Submodule was removed from index but the final commit reported no changes.") success_message = f"Submodule '{submodule_path}' removed successfully." log_handler.log_info(success_message, func_name=func_name) return True, success_message except (GitCommandError, ValueError, IOError) as e: log_handler.log_error(f"Failed to remove submodule '{submodule_path}': {e}", func_name=func_name) return False, f"Failed to remove submodule: {e}" def execute_force_clean_submodule(self, repo_path: str, submodule_path: str) -> Tuple[bool, str]: """ Performs a deep and robust clean of a submodule's configuration. This is the definitive method to resolve an inconsistent submodule state. """ func_name = "execute_force_clean_submodule" log_handler.log_warning( f"--- DESTRUCTIVE ACTION: Forcing deep clean of submodule '{submodule_path}' ---", func_name=func_name ) # Helper per la cancellazione robusta di cartelle... (codice invariato) def _robust_rmtree(path_to_remove): if not os.path.isdir(path_to_remove): log_handler.log_info(f"Directory not found, skipping removal: {path_to_remove}", func_name=func_name) return def _remove_readonly(func, path, exc_info): os.chmod(path, stat.S_IWRITE) func(path) try: shutil.rmtree(path_to_remove, onerror=_remove_readonly) log_handler.log_info(f"Successfully removed directory: {path_to_remove}", func_name=func_name) except Exception as e: log_handler.log_error(f"Failed to remove directory '{path_to_remove}': {e}", func_name=func_name) raise e try: # Step 1: Rimuovi la sezione dal file .git/config config_section_name = f"submodule.{submodule_path}" log_handler.log_debug(f"Step 1: Removing section '{config_section_name}' from .git/config...", func_name=func_name) self.git_commands.remove_config_section(repo_path, config_section_name) # --- MODIFICA CHIAVE QUI --- # Step 2: Rimuovi il submodule e .gitmodules dall'index. # Ignora esplicitamente l'errore se il path non viene trovato. log_handler.log_debug("Step 2: Removing paths from index if they exist...", func_name=func_name) paths_to_remove_from_index = [submodule_path, ".gitmodules"] something_was_removed_from_index = False for path in paths_to_remove_from_index: try: self.git_commands.git_rm(repo_path, path, force=True) # Se il comando ha successo, significa che ha rimosso qualcosa something_was_removed_from_index = True except GitCommandError as rm_error: # Se l'errore è "pathspec did not match", lo ignoriamo. È il nostro obiettivo. if "did not match any file" in (rm_error.stderr or "").lower(): log_handler.log_warning( f"Path '{path}' not found in index. This is expected during cleanup. Continuing.", func_name=func_name ) else: # Se è un altro errore, è un problema e lo solleviamo. raise rm_error # --- FINE MODIFICA --- # Step 3: Pulisci la cartella di lavoro del submodule working_tree_path = os.path.join(repo_path, submodule_path) log_handler.log_debug(f"Step 3: Cleaning submodule working tree directory '{working_tree_path}'...", func_name=func_name) _robust_rmtree(working_tree_path) # Step 4: Pulisci la cartella cache in .git/modules git_modules_path = os.path.join(repo_path, ".git", "modules", submodule_path) log_handler.log_debug(f"Step 4: Cleaning internal git directory '{git_modules_path}'...", func_name=func_name) _robust_rmtree(git_modules_path) # Step 5: Fai il commit finale della pulizia commit_message = f"Chore: Force clean and remove all traces of submodule '{submodule_path}'" log_handler.log_info(f"Step 5: Committing cleanup...", func_name=func_name) # Esegui il commit solo se abbiamo effettivamente rimosso qualcosa dall'index if something_was_removed_from_index: commit_made = self.git_commands.git_commit(repo_path, commit_message, stage_all_first=False) if not commit_made: log_handler.log_warning("Cleanup actions were staged, but commit reported no changes. The state may be complex.") else: log_handler.log_info("No Git index changes to commit. The index was already clean.") success_message = f"Successfully cleaned all traces of submodule '{submodule_path}'. The repository is now in a consistent state." log_handler.log_info(success_message, func_name=func_name) return True, success_message except (GitCommandError, ValueError, IOError, Exception) as e: error_msg = f"Failed to clean submodule state for '{submodule_path}': {e}" log_handler.log_error(error_msg, func_name=func_name, exc_info=True) # Aggiunto exc_info per traceback return False, error_msg def execute_clean_submodule_working_tree(self, repo_path: str, submodule_path: str) -> Tuple[bool, str]: """ Executes 'git clean -fd' inside a specific submodule's directory. Args: repo_path: The path to the parent repository. submodule_path: The local path of the submodule to clean. Returns: A tuple (success, message). """ func_name = "execute_clean_submodule_working_tree" full_submodule_path = os.path.join(repo_path, submodule_path) if not os.path.isdir(full_submodule_path): msg = f"Cannot clean: Submodule directory not found at '{full_submodule_path}'" log_handler.log_error(msg, func_name=func_name) return False, msg log_handler.log_info(f"Executing clean for submodule at '{full_submodule_path}'", func_name=func_name) try: self.git_commands.git_clean(full_submodule_path) success_msg = f"Submodule '{submodule_path}' cleaned successfully." return True, success_msg except GitCommandError as e: error_msg = f"Failed to clean submodule '{submodule_path}': {e}" log_handler.log_error(error_msg, func_name=func_name) return False, error_msg