# --- FILE: gitsync_tool/core/submodule_handler.py --- import os import datetime 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 in the parent repository. Args: repo_path: The path to the parent repository. submodule_url: The URL of the submodule repository to add. submodule_path: The local path within the parent repo for the submodule. Returns: A tuple (success, message). """ func_name = "execute_add_submodule" log_handler.log_info( f"Executing add submodule: URL='{submodule_url}', Path='{submodule_path}'", func_name=func_name ) try: # Step 1: Add the submodule. This stages .gitmodules and the new path. self.git_commands.submodule_add(repo_path, submodule_url, submodule_path) # Step 2: Commit the addition of the submodule. commit_message = f"Feat: Add submodule '{submodule_path}'" log_handler.log_info(f"Committing submodule addition with message: '{commit_message}'", func_name=func_name) commit_made = self.git_commands.git_commit(repo_path, commit_message) if not commit_made: # This should not happen if submodule add succeeded, but as a safeguard: 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. Args: repo_path: The path to the parent repository. Returns: A tuple (success, message, commit_was_made). """ func_name = "execute_sync_all_submodules" log_handler.log_info(f"Executing sync for all submodules in '{repo_path}'", func_name=func_name) try: # 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 (initialize, fetch remote, merge) 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.", 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 to see if anything changed 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 them and commit in the parent repo 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) log_handler.log_debug(f"Staged submodule path: '{path}'", func_name=func_name) 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) 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) return False, msg, False except (GitCommandError, ValueError) as e: log_handler.log_error(f"Failed to sync submodules: {e}", func_name=func_name) return False, f"Failed to sync submodules: {e}", 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. This is a multi-step process. 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 REMOVAL of submodule '{submodule_path}' from '{repo_path}'", func_name=func_name ) try: # Step 1: De-initialize the submodule. This removes the entry from .git/config. log_handler.log_debug(f"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"Running 'git rm' for submodule '{submodule_path}'...", func_name=func_name) self.git_commands.git_rm(repo_path, submodule_path) # The submodule's directory is now removed. # The changes to .gitmodules and the removed path are now staged. # Step 3: Commit the removal. commit_message = f"Refactor: Remove submodule '{submodule_path}'" log_handler.log_info(f"Committing submodule removal with message: '{commit_message}'", func_name=func_name) commit_made = self.git_commands.git_commit(repo_path, commit_message) if not commit_made: raise GitCommandError("Submodule was removed from index but the final commit failed.") # Note: The empty directory .git/modules/ may remain, # which is standard Git behavior and generally harmless. 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) 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}"