diff --git a/gitutility/app.py b/gitutility/app.py index 37aee15..d835502 100644 --- a/gitutility/app.py +++ b/gitutility/app.py @@ -117,6 +117,7 @@ class GitSvnSyncApp: init_missing_submodules_cb=lambda: self.submodule_logic_handler.init_missing_submodules() if self.submodule_logic_handler else None, update_gitea_wiki_cb=lambda: self.automation_handler.update_gitea_wiki() if self.automation_handler else None, analyze_and_clean_history_cb=lambda: self.automation_handler.analyze_and_clean_history() if self.automation_handler else None, + clean_invalid_submodule_cb=lambda: self.automation_handler.clean_invalid_submodule() if self.automation_handler else None, browse_folder_cb=self.browse_folder, update_svn_status_cb=self.update_svn_status_indicator, open_gitignore_editor_cb=self.open_gitignore_editor, @@ -203,6 +204,7 @@ class GitSvnSyncApp: auto_tab = self.main_frame.automation_tab auto_tab.update_gitea_wiki_callback = self.automation_handler.update_gitea_wiki auto_tab.analyze_and_clean_history_callback = self.automation_handler.analyze_and_clean_history + auto_tab.clean_invalid_submodule_callback = self.automation_handler.clean_invalid_submodule def _handle_fatal_error(self, message: str): log_handler.log_critical(message, func_name="_handle_fatal_error") diff --git a/gitutility/async_tasks/async_result_handler.py b/gitutility/async_tasks/async_result_handler.py index fc608ed..9ed9b67 100644 --- a/gitutility/async_tasks/async_result_handler.py +++ b/gitutility/async_tasks/async_result_handler.py @@ -15,6 +15,7 @@ from typing import ( Optional, Set, ) +import re from gitutility.logging_setup import log_handler from gitutility.commands.git_commands import GitCommandError @@ -88,6 +89,8 @@ class AsyncResultHandler: "sync_submodules": self._handle_sync_all_submodules_result, "remove_submodule": self._handle_remove_submodule_result, "init_submodules": self._handle_init_submodules_result, + "force_clean_submodule": self._handle_force_clean_submodule_result, + "clean_submodule": self._handle_clean_submodule_result, } handler_method = handler_map.get(task_context) @@ -450,11 +453,73 @@ class AsyncResultHandler: return False, False def _handle_sync_all_submodules_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: - if result_data.get("status") == "success" and result_data.get("committed"): + status = result_data.get("status") + message = result_data.get("message") + exception = result_data.get("exception") + + # --- NUOVA LOGICA DI RECUPERO (CORRETTA) --- + # Controlliamo se è un GitCommandError e se il suo stderr contiene il messaggio specifico + if isinstance(exception, GitCommandError) and "untracked working tree" in (exception.stderr or "").lower(): + + # Applichiamo la regex a exception.stderr, non a str(exception) + stderr_text = exception.stderr or "" + submodule_path_match = re.search(r"in submodule path '(.*?)'", stderr_text) + + submodule_path = submodule_path_match.group(1) if submodule_path_match else "an unknown submodule" + + # Chiedi all'utente se vuole pulire + if self.main_frame.ask_yes_no( + "Sync Failed: Untracked Files", + f"The update failed because untracked files in submodule '{submodule_path}' would be overwritten.\n\n" + "Do you want to automatically clean these files (git clean -fd) and retry the sync?" + ): + # L'utente ha detto sì, avvia l'operazione di pulizia + repo_path = self.app._get_and_validate_svn_path("Submodule Clean") + if repo_path: + # Se il path è 'unknown', non procediamo per sicurezza + if "unknown" in submodule_path: + self.main_frame.show_error("Error", "Could not automatically determine the submodule path from the error message. Cannot proceed with automatic clean.") + return False, False + + args = (self.app.submodule_handler, repo_path, submodule_path) + self.app._start_async_operation( + async_workers.run_clean_submodule_async, + args, + { + "context": "clean_submodule", + "status_msg": f"Cleaning submodule '{submodule_path}'...", + "on_success_retry": "sync_all" + } + ) + return False, False + else: + # L'utente ha detto no, mostra l'errore originale + self.main_frame.show_error("Sync Submodules Error", f"Failed:\n{message}") + return False, False + + # --- Logica esistente per altri casi --- + elif status == "success" and result_data.get("committed"): return True, True - elif result_data.get("status") == "error": - self.main_frame.show_error("Sync Submodules Error", f"Failed:\n{result_data.get('message')}") + elif status == "error": + self.main_frame.show_error("Sync Submodules Error", f"Failed:\n{message}") + return False, False + + def _handle_clean_submodule_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: + """Handles the result of the submodule cleaning operation.""" + if result_data.get("status") == "success": + self.main_frame.show_info("Submodule Cleaned", result_data.get("message")) + + # Controlla se dobbiamo ritentare un'azione + retry_action = context.get("on_success_retry") + if retry_action == "sync_all": + log_handler.log_info("Clean successful, retrying 'Sync/Update All' operation.", func_name="_handle_clean_submodule_result") + # Lancia di nuovo l'operazione di sync + self.app.submodule_logic_handler.sync_all_submodules() + else: + self.main_frame.show_error("Clean Failed", f"The submodule cleaning process failed:\n\n{result_data.get('message')}") + + return False, False # Il refresh verrà triggerato dall'azione successiva (o nessuno se fallisce) def _handle_remove_submodule_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: if result_data.get("status") == "success": @@ -469,6 +534,16 @@ class AsyncResultHandler: else: self.main_frame.show_error("Initialize Submodules Error", f"Failed:\n{result_data.get('message')}") return False, False + + def _handle_force_clean_submodule_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: + """Handles the result of the submodule cleaning operation.""" + if result_data.get("status") == "success": + self.main_frame.show_info("Operation Successful", result_data.get("message")) + # Trigger a full refresh because the index and history have changed + return True, True + else: + self.main_frame.show_error("Clean Failed", f"The submodule cleaning process failed:\n\n{result_data.get('message')}") + return False, False def _handle_generic_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: status, message = result_data.get("status"), result_data.get("message", "Operation finished.") diff --git a/gitutility/async_tasks/async_workers.py b/gitutility/async_tasks/async_workers.py index f98ad1d..a660daf 100644 --- a/gitutility/async_tasks/async_workers.py +++ b/gitutility/async_tasks/async_workers.py @@ -6,6 +6,7 @@ import logging # Usato solo per i livelli di logging (es. logging.INFO) import datetime from typing import List, Dict, Any, Tuple, Optional, Set import subprocess +import re # Importa usando il percorso assoluto dal pacchetto from gitutility.logging_setup import log_handler @@ -1101,8 +1102,18 @@ def run_sync_all_submodules_async( log_handler.log_debug(f"[Worker] Started: Sync All Submodules in '{repo_path}'", func_name=func_name) result_payload: Dict[str, Any] = {"status": "error", "message": "Submodule sync failed.", "exception": None, "committed": False} try: + # Questa chiamata ora solleva un'eccezione in caso di errore success, message, commit_made = submodule_handler.execute_sync_all_submodules(repo_path) - result_payload.update(status="success" if success else "error", message=message, committed=commit_made) + # Se arriviamo qui, ha avuto successo + result_payload.update(status="success", message=message, committed=commit_made) + except (GitCommandError, ValueError) as e: + # Ora questo blocco verrà eseguito correttamente! + log_handler.log_error(f"[Worker] Caught expected exception during submodule sync: {e}", func_name=func_name) + result_payload.update( + status="error", + message=f"Failed to sync submodules: {e}", + exception=e + ) except Exception as e: log_handler.log_exception(f"[Worker] EXCEPTION syncing submodules: {e}", func_name=func_name) result_payload.update(exception=e, message=f"Error syncing submodules: {e}") @@ -1147,4 +1158,44 @@ def run_init_submodules_async( result_payload.update(exception=e, message=f"Error initializing submodules: {e}") finally: results_queue.put(result_payload) - log_handler.log_debug(f"[Worker] Finished: Initialize Missing Submodules", func_name=func_name) \ No newline at end of file + log_handler.log_debug(f"[Worker] Finished: Initialize Missing Submodules", func_name=func_name) + +def run_force_clean_submodule_async( + submodule_handler: SubmoduleHandler, + repo_path: str, + submodule_path: str, + results_queue: queue.Queue[Dict[str, Any]], +) -> None: + """Worker to perform a deep clean of a submodule's configuration.""" + func_name = "run_force_clean_submodule_async" + log_handler.log_debug(f"[Worker] Started: Force Clean Submodule '{submodule_path}'", func_name=func_name) + result_payload: Dict[str, Any] = {"status": "error", "message": "Submodule clean failed.", "exception": None, "committed": False} + try: + success, message = submodule_handler.execute_force_clean_submodule(repo_path, submodule_path) + result_payload.update(status="success" if success else "error", message=message, committed=success) + except Exception as e: + log_handler.log_exception(f"[Worker] EXCEPTION during force clean submodule: {e}", func_name=func_name) + result_payload.update(exception=e, message=f"Error during submodule clean: {e}") + finally: + results_queue.put(result_payload) + log_handler.log_debug(f"[Worker] Finished: Force Clean Submodule", func_name=func_name) + +def run_clean_submodule_async( + submodule_handler: SubmoduleHandler, + repo_path: str, + submodule_path: str, + results_queue: queue.Queue[Dict[str, Any]], +) -> None: + """Worker to clean the working tree of a single submodule.""" + func_name = "run_clean_submodule_async" + log_handler.log_debug(f"[Worker] Started: Clean Submodule '{submodule_path}'", func_name=func_name) + result_payload: Dict[str, Any] = {"status": "error", "message": "Submodule clean failed."} + try: + success, message = submodule_handler.execute_clean_submodule_working_tree(repo_path, submodule_path) + result_payload.update(status="success" if success else "error", message=message) + except Exception as e: + log_handler.log_exception(f"[Worker] EXCEPTION cleaning submodule: {e}", func_name=func_name) + result_payload.update(message=f"Error cleaning submodule: {e}") + finally: + results_queue.put(result_payload) + log_handler.log_debug(f"[Worker] Finished: Clean Submodule", func_name=func_name) \ No newline at end of file diff --git a/gitutility/commands/git_commands.py b/gitutility/commands/git_commands.py index 1bfd45b..630d14e 100644 --- a/gitutility/commands/git_commands.py +++ b/gitutility/commands/git_commands.py @@ -1273,7 +1273,11 @@ class GitCommands: func_name = "get_file_content_from_ref" git_file_path = file_path.replace(os.path.sep, "/") git_ref = ref.strip() if ref else "HEAD" - ref_path_arg = f"{git_ref}:{git_file_path}" + if git_ref == ":": + # La sintassi corretta per l'index è ":", non "::" + ref_path_arg = f":{git_file_path}" + else: + ref_path_arg = f"{git_ref}:{git_file_path}" log_handler.log_debug( f"Getting content for file='{git_file_path}' at ref='{git_ref}' (using '{ref_path_arg}') in '{working_directory}'", func_name=func_name, @@ -2218,8 +2222,7 @@ class GitCommands: def submodule_status(self, working_directory: str) -> List[Dict[str, str]]: """ Gets the status of all submodules recursively and parses the output. - Output format: ' ' (up-to-date), '-' (not initialized), '+' (commit mismatch), 'U' (conflict) - Example line: '-b82312... gitsync_tool/logging_setup (unspecified)' + Handles multiple output formats from the 'git submodule status' command. """ func_name = "submodule_status" log_handler.log_debug(f"Getting submodule status in '{working_directory}'", func_name=func_name) @@ -2227,8 +2230,15 @@ class GitCommands: result = self.log_and_execute(cmd, working_directory, check=True, log_output_level=logging.DEBUG) submodules = [] - # Regex to parse the status line - status_regex = re.compile(r"^\s*([ U+-])([0-9a-fA-F]+)\s(.*?)\s\((.*?)\)\s*$") + # This regex is now more robust and handles two main formats: + # 1. [U+- ] () <- Status character is present + # 2. () <- Status character is absent (for clean submodules) + status_regex = re.compile( + r"^\s*([U +-])?" # 1. Status character (optional) + r"([0-9a-fA-F]+)\s" # 2. Commit hash (mandatory) + r"(.*?)" # 3. Path (non-greedy) + r"(?:\s\((.*?)\))?\s*$" # 4. Description in parentheses (optional) + ) for line in result.stdout.strip().splitlines(): line = line.strip() @@ -2238,11 +2248,15 @@ class GitCommands: match = status_regex.match(line) if match: status_char, commit, path, description = match.groups() + + # If status_char is None (because it was optional), default to ' ' (clean) + final_status_char = status_char.strip() if status_char else ' ' + submodules.append({ - "status_char": status_char, - "commit": commit, + "status_char": final_status_char, + "commit": commit.strip(), "path": path.strip(), - "description": description.strip(), + "description": (description or "N/A").strip(), }) else: log_handler.log_warning(f"Could not parse submodule status line: '{line}'", func_name=func_name) @@ -2273,4 +2287,208 @@ class GitCommands: log_handler.log_info(f"De-initializing submodule at '{path}'", func_name=func_name) # Use -f to force deinit even with local changes cmd = ["git", "submodule", "deinit", "-f", "--", path] - self.log_and_execute(cmd, working_directory, check=True) \ No newline at end of file + self.log_and_execute(cmd, working_directory, check=True) + + def remove_from_index(self, working_directory: str, path: str) -> None: + """ + Forcibly removes a path from the Git index (staging area) only. + This does not delete the file from the working directory. + + Args: + working_directory (str): Path to the repository. + path (str): The relative path to the item to remove from the index. + + Raises: + GitCommandError: If the 'git rm --cached' command fails. + """ + func_name = "remove_from_index" + log_handler.log_info( + f"Forcibly removing path from index (cached): '{path}' in '{working_directory}'", + func_name=func_name + ) + # Use -f to force removal even if there are local modifications + cmd = ["git", "rm", "--cached", "-f", path] + try: + self.log_and_execute(cmd, working_directory, check=True) + log_handler.log_info(f"Path '{path}' removed from index successfully.", func_name=func_name) + except GitCommandError as e: + log_handler.log_error(f"Failed to 'git rm --cached {path}': {e}", func_name=func_name) + raise + + def remove_config_section(self, working_directory: str, section_name: str) -> None: + """ + Removes a section from the local .git/config file. + + Args: + working_directory (str): Path to the repository. + section_name (str): The full name of the section to remove (e.g., 'submodule.libs/path'). + + Raises: + GitCommandError: If the command fails with an unexpected error. + """ + func_name = "remove_config_section" + log_handler.log_info( + f"Removing config section '{section_name}' from local .git/config", + func_name=func_name + ) + cmd = ["git", "config", "-f", ".git/config", "--remove-section", section_name] + try: + result = self.log_and_execute(cmd, working_directory, check=False) + + # Git returns non-zero if the section doesn't exist. This is not an error for a clean-up operation. + # We only raise an error if the command fails for a different reason. + if result.returncode != 0 and "no such section" not in (result.stderr or "").lower(): + raise GitCommandError( + f"Git config command failed with an unexpected error (RC {result.returncode})", + command=cmd, + stderr=result.stderr + ) + + log_handler.log_info(f"Config section '{section_name}' removed (if it existed).", func_name=func_name) + except GitCommandError as e: + log_handler.log_error(f"Failed to remove config section '{section_name}': {e}", func_name=func_name) + raise + + def get_registered_submodules(self, working_directory: str) -> Dict[str, str]: + """ + Retrieves the list of registered submodules and their URLs from the .gitmodules file. + This reads the configuration, not the on-disk status. + + Args: + working_directory (str): Path to the repository. + + Returns: + A dictionary mapping submodule paths to their URLs. + """ + func_name = "get_registered_submodules" + log_handler.log_debug( + f"Getting registered submodules from config in '{working_directory}'", + func_name=func_name + ) + submodules = {} + # The command 'git config --file .gitmodules --get-regexp path' is perfect for this. + # It reads .gitmodules and finds all entries that have a 'path' property. + cmd = ["git", "config", "--file", ".gitmodules", "--get-regexp", r"submodule\..*\.path"] + + try: + result = self.log_and_execute(cmd, working_directory, check=False) + + # If .gitmodules doesn't exist, the command will fail, which is okay. + if result.returncode != 0 or not result.stdout: + log_handler.log_info("No .gitmodules file found or it's empty. No registered submodules.", func_name=func_name) + return {} + + # Output is like: submodule.libs/log-handler-lib.path libs/log-handler-lib + for line in result.stdout.strip().splitlines(): + # Extract the section name (e.g., submodule.libs/log-handler-lib) and the path + key, path = line.split(maxsplit=1) + submodule_name = key.replace("submodule.", "").replace(".path", "") + + # Now get the URL for this submodule + url_cmd = ["git", "config", "--file", ".gitmodules", f"submodule.{submodule_name}.url"] + url_result = self.log_and_execute(url_cmd, working_directory, check=True) + url = url_result.stdout.strip() + + submodules[path] = url + + log_handler.log_info(f"Found {len(submodules)} registered submodules: {list(submodules.keys())}", func_name=func_name) + return submodules + + except GitCommandError as e: + # This can happen if .gitmodules is malformed. + log_handler.log_error(f"Failed to read registered submodules: {e}", func_name=func_name) + return {} # Return empty on error + + def discover_submodules_in_any_state(self, working_directory: str) -> List[str]: + """ + Discovers submodule paths by looking in multiple Git locations: + the checked-out .gitmodules, the version of .gitmodules in the index, + and the .git/config file. This is useful for finding submodules in + an inconsistent state. + + Args: + working_directory (str): Path to the repository. + + Returns: + A list of unique submodule paths found. + """ + func_name = "discover_submodules_in_any_state" + log_handler.log_debug( + f"Discovering submodules in any state in '{working_directory}'", + func_name=func_name + ) + + found_paths = set() + + # --- NUOVO STEP FONDAMENTALE: Leggere .gitmodules dall'INDEX --- + # Questo trova i submodule che sono stati "aggiunti" ma non ancora committati, + # o che sono rimasti nell'index dopo una cancellazione fallita. + try: + # 'git show :.gitmodules' legge il contenuto del file dall'index + gitmodules_content_from_index = self.get_file_content_from_ref( + working_directory, + file_path=".gitmodules", + ref=":" # Il ':' da solo significa "dall'index" + ) + if gitmodules_content_from_index: + log_handler.log_info("Found .gitmodules in Git index. Parsing its content.", func_name=func_name) + # Usiamo una regex per estrarre tutti i 'path = ...' dal contenuto + for match in re.finditer(r"^\s*path\s*=\s*(.*)$", gitmodules_content_from_index, re.MULTILINE): + path = match.group(1).strip() + if path: + found_paths.add(path) + except GitCommandError as e: + log_handler.log_warning(f"Could not read .gitmodules from index (it may not be staged): {e}", func_name=func_name) + except Exception as e_index: + log_handler.log_error(f"Error parsing .gitmodules content from index: {e_index}", func_name=func_name) + + # 1. Look in the on-disk .gitmodules file (the standard way) + try: + cmd_gitmodules = ["git", "config", "--file", ".gitmodules", "--get-regexp", r"submodule\..*\.path"] + result_gitmodules = self.log_and_execute(cmd_gitmodules, working_directory, check=False) + if result_gitmodules.returncode == 0 and result_gitmodules.stdout: + for line in result_gitmodules.stdout.strip().splitlines(): + path = line.split(maxsplit=1)[1] + found_paths.add(path) + except GitCommandError as e: + log_handler.log_warning(f"Could not read on-disk .gitmodules: {e}", func_name=func_name) + + # 2. Look in .git/config for submodule sections + try: + cmd_config = ["git", "config", "-f", ".git/config", "--get-regexp", r"submodule\..*\.path"] + result_config = self.log_and_execute(cmd_config, working_directory, check=False) + if result_config.returncode == 0 and result_config.stdout: + for line in result_config.stdout.strip().splitlines(): + path = line.split(maxsplit=1)[1] + found_paths.add(path) + except GitCommandError as e: + log_handler.log_warning(f"Could not read submodule paths from .git/config: {e}", func_name=func_name) + + sorted_paths = sorted(list(found_paths)) + log_handler.log_info(f"Discovered {len(sorted_paths)} unique submodule paths: {sorted_paths}", func_name=func_name) + return sorted_paths + + def git_clean(self, working_directory: str) -> None: + """ + Cleans the working tree by recursively removing untracked files + from the current directory. + + Args: + working_directory (str): The directory to clean. + + Raises: + GitCommandError: If the 'git clean' command fails. + """ + func_name = "git_clean" + log_handler.log_warning( + f"Performing 'git clean -fd' in '{working_directory}'. This will delete untracked files.", + func_name=func_name + ) + # -f (force) is required. -d removes untracked directories. + cmd = ["git", "clean", "-fd"] + try: + self.log_and_execute(cmd, working_directory, check=True) + log_handler.log_info(f"Successfully cleaned working directory: '{working_directory}'", func_name=func_name) + except GitCommandError as e: + log_handler.log_error(f"Failed to clean directory '{working_directory}': {e}", func_name=func_name) + raise \ No newline at end of file diff --git a/gitutility/core/submodule_handler.py b/gitutility/core/submodule_handler.py index a7875e0..7e91043 100644 --- a/gitutility/core/submodule_handler.py +++ b/gitutility/core/submodule_handler.py @@ -2,6 +2,9 @@ import os import datetime +import shutil +import time +import stat from typing import Tuple, List, Optional # Import using absolute path from the package @@ -70,74 +73,75 @@ class SubmoduleHandler: """ 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) - 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)} + # 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 (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 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 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 + # 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) - 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]}") + 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) + 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) - 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 + 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]: """ @@ -212,4 +216,144 @@ class SubmoduleHandler: 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}" \ No newline at end of file + 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 clean of a submodule's configuration from the repository. + This is a destructive operation intended to fix an inconsistent state. + + 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_force_clean_submodule" + log_handler.log_warning( + f"--- DESTRUCTIVE ACTION: Forcing clean of submodule '{submodule_path}' ---", + func_name=func_name + ) + try: + # 1. De-initialize. We expect this might fail if the submodule was never properly registered. + log_handler.log_debug("Step 1: Attempting to de-initialize submodule...", func_name=func_name) + try: + self.git_commands.submodule_deinit(repo_path, submodule_path) + except GitCommandError as deinit_error: + if "did not match any file(s)" in (deinit_error.stderr or "").lower(): + log_handler.log_warning( + f"Submodule deinit failed as expected because '{submodule_path}' is not a known submodule. Continuing cleanup.", + func_name=func_name + ) + else: + raise deinit_error + + # 2. Forcibly remove submodule path AND .gitmodules from the index. + # This is the core of the cleanup for an inconsistent state. + # We attempt both and ignore "did not match" errors. + paths_to_remove_from_index = [submodule_path, ".gitmodules"] + log_handler.log_debug(f"Step 2: Forcibly removing paths from index: {paths_to_remove_from_index}", func_name=func_name) + for path in paths_to_remove_from_index: + try: + self.git_commands.remove_from_index(repo_path, path) + except GitCommandError as rm_error: + if "did not match any files" in (rm_error.stderr or "").lower(): + log_handler.log_warning( + f"Path '{path}' was not in the index. Continuing cleanup.", + func_name=func_name + ) + else: + raise rm_error # Re-raise unexpected errors + + # 3. Forcibly remove the section from .git/config + config_section_name = f"submodule.{submodule_path}" + log_handler.log_debug(f"Step 3: Removing section '{config_section_name}' from .git/config...", func_name=func_name) + self.git_commands.remove_config_section(repo_path, config_section_name) + + # 4. Remove the submodule's directory from .git/modules + git_modules_path = os.path.join(repo_path, ".git", "modules", submodule_path) + log_handler.log_debug(f"Step 4: Removing internal git directory '{git_modules_path}'...", func_name=func_name) + + def _remove_readonly(func, path, exc_info): + """ + Error handler for shutil.rmtree. It's called on error. + It attempts to remove the readonly bit and re-execute the failing function. + """ + # exc_info contains type, value, traceback + exc_type, exc_value, _ = exc_info + # Check if it's a permission error + if issubclass(exc_type, PermissionError) or (hasattr(exc_value, 'winerror') and exc_value.winerror == 5): + log_handler.log_warning(f"Permission error on '{path}'. Attempting to change permissions and retry...", func_name=func_name) + try: + # Change permissions to writable and try again + os.chmod(path, stat.S_IWRITE) + func(path) # Retry the original function (e.g., os.remove) + except Exception as e: + log_handler.log_error(f"Failed to remove '{path}' even after changing permissions: {e}", func_name=func_name) + # We re-raise the original exception to let rmtree know it still failed + raise exc_value + else: + # If it's not a permission error, re-raise it + raise exc_value + + if os.path.isdir(git_modules_path): + try: + # Call rmtree with our custom error handler + shutil.rmtree(git_modules_path, onerror=_remove_readonly) + log_handler.log_info(f"Removed directory: {git_modules_path}", func_name=func_name) + except Exception as e: + # If rmtree still fails after all retries from the handler, log it. + log_handler.log_error(f"Failed to remove directory '{git_modules_path}' with robust handler: {e}", func_name=func_name) + # Re-raise the exception to make the whole operation fail + raise e + else: + log_handler.log_info(f"Internal directory not found, skipping: {git_modules_path}", func_name=func_name) + + # 5. Commit the changes (the removal from the index) + commit_message = f"Chore: Force clean inconsistent submodule state for '{submodule_path}'" + log_handler.log_info(f"Step 5: Committing cleanup with message: '{commit_message}'", func_name=func_name) + # Le operazioni 'git rm --cached' hanno già preparato l'index. + # Eseguiamo un commit diretto senza fare un altro 'git add'. + 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 performed, but no changes were committed. The state might have already been clean.", func_name=func_name) + + success_message = f"Successfully cleaned submodule state for '{submodule_path}'. The repository should now be in a consistent state." + log_handler.log_info(success_message, func_name=func_name) + return True, success_message + + except (GitCommandError, ValueError, IOError) as e: + error_msg = f"Failed to clean submodule state for '{submodule_path}': {e}" + log_handler.log_error(error_msg, func_name=func_name) + 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 \ No newline at end of file diff --git a/gitutility/gui/dialogs.py b/gitutility/gui/dialogs.py index b1da531..7b6ebc1 100644 --- a/gitutility/gui/dialogs.py +++ b/gitutility/gui/dialogs.py @@ -4,7 +4,7 @@ import tkinter as tk from tkinter import ttk, messagebox, simpledialog, filedialog import os import re # Per validazione nomi branch/tag -from typing import Optional, Tuple +from typing import Optional, Tuple, List # --- Tooltip (non necessario qui, i dialoghi sono semplici) --- @@ -324,4 +324,117 @@ class CloneFromRemoteDialog(simpledialog.Dialog): ) +class SelectSubmoduleDialog(simpledialog.Dialog): + """Dialog to select a submodule from a list for cleaning.""" + + def __init__(self, parent, title: str = "Select Submodule to Clean", submodule_paths: List[str] = None): + self.submodule_paths = submodule_paths if submodule_paths else [] + self.result: Optional[str] = None + self.selected_path_var = tk.StringVar() + super().__init__(parent, title=title) + + def body(self, master: tk.Frame) -> Optional[tk.Widget]: + frame = ttk.Frame(master, padding="10") + frame.pack(fill="both", expand=True) + + ttk.Label( + frame, + text="Select the submodule with an inconsistent state to clean from the repository:" + ).pack(pady=(0, 10)) + + if not self.submodule_paths: + ttk.Label(frame, text="No registered submodules found in .gitmodules.", foreground="red").pack() + return None # No items to select + + self.combobox = ttk.Combobox( + frame, + textvariable=self.selected_path_var, + values=self.submodule_paths, + state="readonly", + width=50 + ) + self.combobox.pack(pady=5) + if self.submodule_paths: + self.combobox.current(0) # Pre-select the first item + + return self.combobox + + def validate(self) -> bool: + if not self.selected_path_var.get(): + messagebox.showwarning("Selection Required", "You must select a submodule path from the list.", parent=self) + return False + return True + + def apply(self) -> None: + self.result = self.selected_path_var.get() + +class AddSubmoduleDialog(simpledialog.Dialog): + """Dialog to get a new submodule's URL and local path.""" + + def __init__(self, parent, title: str = "Add New Submodule"): + self.url_var = tk.StringVar() + self.path_var = tk.StringVar() + self.result: Optional[Tuple[str, str]] = None + super().__init__(parent, title=title) + + def body(self, master: tk.Frame) -> Optional[tk.Widget]: + """Creates the dialog body.""" + frame = ttk.Frame(master, padding="10") + frame.pack(fill="x", expand=True) + frame.columnconfigure(1, weight=1) + + # Repository URL Row + ttk.Label(frame, text="Repository URL:").grid( + row=0, column=0, padx=5, pady=5, sticky="w" + ) + self.url_entry = ttk.Entry(frame, textvariable=self.url_var, width=60) #<-- Larghezza aumentata + self.url_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + # Local Path Row + ttk.Label(frame, text="Local Path:").grid( + row=1, column=0, padx=5, pady=5, sticky="w" + ) + self.path_entry = ttk.Entry(frame, textvariable=self.path_var, width=60) #<-- Larghezza aumentata + self.path_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew") + + ttk.Label( + frame, + text="(e.g., libs/my-submodule)", + font=("Segoe UI", 8), + foreground="grey" + ).grid(row=2, column=1, padx=5, sticky="w") + + + return self.url_entry # Initial focus + + def validate(self) -> bool: + """Validates the input.""" + url = self.url_var.get().strip() + path = self.path_var.get().strip() + + if not url: + messagebox.showwarning("Input Error", "Repository URL cannot be empty.", parent=self) + self.url_entry.focus_set() + return False + + if not path: + messagebox.showwarning("Input Error", "Local Path cannot be empty.", parent=self) + self.path_entry.focus_set() + return False + + # Basic check for invalid path characters + if any(char in path for char in ['<', '>', ':', '"', '|', '?', '*']): + messagebox.showwarning("Input Error", f"Local path '{path}' contains invalid characters.", parent=self) + self.path_entry.focus_set() + return False + + return True + + def apply(self) -> None: + """Stores the validated result.""" + self.result = ( + self.url_var.get().strip(), + self.path_var.get().strip().replace("\\", "/") # Normalize path separators + ) + # --- END OF FILE gitsync_tool/gui/dialogs.py --- diff --git a/gitutility/gui/tabs/automation_tab.py b/gitutility/gui/tabs/automation_tab.py index 7a2f44e..09e2c55 100644 --- a/gitutility/gui/tabs/automation_tab.py +++ b/gitutility/gui/tabs/automation_tab.py @@ -26,6 +26,7 @@ class AutomationTab(ttk.Frame): # Store callbacks self.update_gitea_wiki_callback = kwargs.get('update_gitea_wiki_cb') self.analyze_and_clean_history_callback = kwargs.get('analyze_and_clean_history_cb') + self.clean_invalid_submodule_callback = kwargs.get('clean_invalid_submodule_cb') # Get a reference to the main frame to access shared variables (like remote_url_var) self.main_frame = self.master.master @@ -89,6 +90,31 @@ class AutomationTab(ttk.Frame): "DESTRUCTIVE: Analyze history for files to remove.\n" "This action can rewrite the entire repository history." ) + + clean_frame = ttk.LabelFrame( + self, text="Submodule Maintenance", padding=(10, 5) + ) + clean_frame.grid(row=2, column=0, sticky="ew") + ttk.Label( + clean_frame, + text="DESTRUCTIVE: Forcibly removes all traces of a submodule from the Git index and config.\n", + wraplength=450, + justify=tk.LEFT + ).pack(pady=(0, 10), fill="x", expand=True) + + self.clean_submodule_button = ttk.Button( + clean_frame, + text="Clean Invalid Submodule...", + command=self.clean_invalid_submodule_callback, # Aggiungeremo questo callback + state=tk.DISABLED + ) + self.clean_submodule_button.pack(pady=5) + Tooltip( + self.clean_submodule_button, + "DESTRUCTIVE: Forcibly removes all traces of a submodule from the Git index and config.\n" + "Use this to fix a repository stuck in an inconsistent state after a failed submodule operation." + ) + def set_action_widgets_state(self, state: str) -> None: """ @@ -109,6 +135,7 @@ class AutomationTab(ttk.Frame): widgets = [ self.update_wiki_button, self.analyze_history_button, + self.clean_submodule_button, ] for widget in widgets: diff --git a/gitutility/logic/automation_handler.py b/gitutility/logic/automation_handler.py index c7e9f15..1b205b3 100644 --- a/gitutility/logic/automation_handler.py +++ b/gitutility/logic/automation_handler.py @@ -1,10 +1,12 @@ # --- FILE: gitsync_tool/logic/automation_handler.py --- from typing import TYPE_CHECKING +from tkinter import simpledialog from gitutility.async_tasks import async_workers from gitutility.logging_setup import log_handler from gitutility.config.config_manager import DEFAULT_REMOTE_NAME +from gitutility.gui.dialogs import SelectSubmoduleDialog # Forward reference for type hinting if TYPE_CHECKING: @@ -77,4 +79,51 @@ class AutomationHandler: "status_msg": "Analyzing repository history...", "repo_path": svn_path, }, - ) \ No newline at end of file + ) + + def clean_invalid_submodule(self): + """Handles the workflow for cleaning an invalid submodule state.""" + func_name = "clean_invalid_submodule" + log_handler.log_info(f"--- Action Triggered: Clean Invalid Submodule ---", func_name=func_name) + + svn_path = self.app._get_and_validate_svn_path("Clean Submodule State") + if not svn_path or not self.app._is_repo_ready(svn_path): + self.main_frame.show_error("Action Failed", "Repository path is invalid or not prepared.") + return + + try: + # --- NUOVA LOGICA: Usa il metodo di scoperta potenziato --- + discovered_paths = self.app.git_commands.discover_submodules_in_any_state(svn_path) + if not discovered_paths: + self.main_frame.show_info("No Submodules Found", "No submodule configurations were found in .gitmodules, .git/config, or the Git index.") + return + + # Mostra la finestra di dialogo con la lista trovata + dialog = SelectSubmoduleDialog(self.app.master, submodule_paths=discovered_paths) + submodule_path = dialog.result + # -------------------------------------------------- + + if not submodule_path: + self.main_frame.update_status_bar("Clean submodule cancelled.") + return + + if not self.main_frame.ask_yes_no( + "Confirm Destructive Clean", + f"This will forcibly remove all traces of '{submodule_path}' from the Git index and config, then create a commit.\n\n" + "This is intended to fix a broken state. Proceed?" + ): + self.main_frame.update_status_bar("Clean submodule cancelled.") + return + + args = (self.app.submodule_handler, svn_path, submodule_path) + self.app._start_async_operation( + worker_func=async_workers.run_force_clean_submodule_async, + args_tuple=args, + context_dict={ + "context": "force_clean_submodule", + "status_msg": f"Cleaning submodule '{submodule_path}'..." + } + ) + except Exception as e: + log_handler.log_exception(f"Error during submodule clean setup: {e}", func_name=func_name) + self.main_frame.show_error("Error", f"Could not prepare submodule clean operation:\n{e}") \ No newline at end of file diff --git a/gitutility/logic/submodule_handler.py b/gitutility/logic/submodule_handler.py index 658f824..1ce5d42 100644 --- a/gitutility/logic/submodule_handler.py +++ b/gitutility/logic/submodule_handler.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Optional, Tuple from gitutility.async_tasks import async_workers from gitutility.logging_setup import log_handler +from gitutility.gui.dialogs import AddSubmoduleDialog # Forward reference for type hinting to avoid circular import if TYPE_CHECKING: @@ -51,8 +52,11 @@ class SubmoduleLogicHandler: self.main_frame.show_error("Action Failed", "Repository is not ready.") return - # Use the dialog method on MainFrame to get user input - details: Optional[Tuple[str, str]] = self.main_frame.ask_new_submodule_details() + # --- MODIFICA: Usa il nuovo dialogo personalizzato --- + dialog = AddSubmoduleDialog(self.app.master) + details = dialog.result + # --------------------------------------------------- + if not details: self.main_frame.update_status_bar("Add submodule cancelled.") return @@ -60,7 +64,7 @@ class SubmoduleLogicHandler: submodule_url, submodule_path = details log_handler.log_info(f"Attempting to add submodule '{submodule_url}' at path '{submodule_path}'", func_name=func_name) - args = (self.submodule_handler, svn_path, submodule_url, submodule_path) + args = (self.app.submodule_handler, svn_path, submodule_url, submodule_path) self.app._start_async_operation( worker_func=async_workers.run_add_submodule_async, args_tuple=args, @@ -125,4 +129,6 @@ class SubmoduleLogicHandler: worker_func=async_workers.run_init_submodules_async, args_tuple=args, context_dict={"context": "init_submodules", "status_msg": "Initializing missing submodules..."}, - ) \ No newline at end of file + ) + + \ No newline at end of file