rmassive refactor, add submodule, add clean inconsiistence submodule function

This commit is contained in:
VALLONGOL 2025-07-30 13:20:32 +02:00
parent c6ccd6e10f
commit e0fbb13437
9 changed files with 758 additions and 73 deletions

View File

@ -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, 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, 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, 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, browse_folder_cb=self.browse_folder,
update_svn_status_cb=self.update_svn_status_indicator, update_svn_status_cb=self.update_svn_status_indicator,
open_gitignore_editor_cb=self.open_gitignore_editor, open_gitignore_editor_cb=self.open_gitignore_editor,
@ -203,6 +204,7 @@ class GitSvnSyncApp:
auto_tab = self.main_frame.automation_tab auto_tab = self.main_frame.automation_tab
auto_tab.update_gitea_wiki_callback = self.automation_handler.update_gitea_wiki 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.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): def _handle_fatal_error(self, message: str):
log_handler.log_critical(message, func_name="_handle_fatal_error") log_handler.log_critical(message, func_name="_handle_fatal_error")

View File

@ -15,6 +15,7 @@ from typing import (
Optional, Optional,
Set, Set,
) )
import re
from gitutility.logging_setup import log_handler from gitutility.logging_setup import log_handler
from gitutility.commands.git_commands import GitCommandError from gitutility.commands.git_commands import GitCommandError
@ -88,6 +89,8 @@ class AsyncResultHandler:
"sync_submodules": self._handle_sync_all_submodules_result, "sync_submodules": self._handle_sync_all_submodules_result,
"remove_submodule": self._handle_remove_submodule_result, "remove_submodule": self._handle_remove_submodule_result,
"init_submodules": self._handle_init_submodules_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) handler_method = handler_map.get(task_context)
@ -450,12 +453,74 @@ class AsyncResultHandler:
return False, False return False, False
def _handle_sync_all_submodules_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: 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 return True, True
elif result_data.get("status") == "error": elif status == "error":
self.main_frame.show_error("Sync Submodules Error", f"Failed:\n{result_data.get('message')}") self.main_frame.show_error("Sync Submodules Error", f"Failed:\n{message}")
return False, False 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]: def _handle_remove_submodule_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]:
if result_data.get("status") == "success": if result_data.get("status") == "success":
return True, True return True, True
@ -470,6 +535,16 @@ class AsyncResultHandler:
self.main_frame.show_error("Initialize Submodules Error", f"Failed:\n{result_data.get('message')}") self.main_frame.show_error("Initialize Submodules Error", f"Failed:\n{result_data.get('message')}")
return False, False 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]: 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.") status, message = result_data.get("status"), result_data.get("message", "Operation finished.")
if status == "error": if status == "error":

View File

@ -6,6 +6,7 @@ import logging # Usato solo per i livelli di logging (es. logging.INFO)
import datetime import datetime
from typing import List, Dict, Any, Tuple, Optional, Set from typing import List, Dict, Any, Tuple, Optional, Set
import subprocess import subprocess
import re
# Importa usando il percorso assoluto dal pacchetto # Importa usando il percorso assoluto dal pacchetto
from gitutility.logging_setup import log_handler 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) 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} result_payload: Dict[str, Any] = {"status": "error", "message": "Submodule sync failed.", "exception": None, "committed": False}
try: try:
# Questa chiamata ora solleva un'eccezione in caso di errore
success, message, commit_made = submodule_handler.execute_sync_all_submodules(repo_path) 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: except Exception as e:
log_handler.log_exception(f"[Worker] EXCEPTION syncing submodules: {e}", func_name=func_name) 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}") result_payload.update(exception=e, message=f"Error syncing submodules: {e}")
@ -1148,3 +1159,43 @@ def run_init_submodules_async(
finally: finally:
results_queue.put(result_payload) results_queue.put(result_payload)
log_handler.log_debug(f"[Worker] Finished: Initialize Missing Submodules", func_name=func_name) 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)

View File

@ -1273,7 +1273,11 @@ class GitCommands:
func_name = "get_file_content_from_ref" func_name = "get_file_content_from_ref"
git_file_path = file_path.replace(os.path.sep, "/") git_file_path = file_path.replace(os.path.sep, "/")
git_ref = ref.strip() if ref else "HEAD" 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 è ":<path>", non "::<path>"
ref_path_arg = f":{git_file_path}"
else:
ref_path_arg = f"{git_ref}:{git_file_path}"
log_handler.log_debug( log_handler.log_debug(
f"Getting content for file='{git_file_path}' at ref='{git_ref}' (using '{ref_path_arg}') in '{working_directory}'", f"Getting content for file='{git_file_path}' at ref='{git_ref}' (using '{ref_path_arg}') in '{working_directory}'",
func_name=func_name, func_name=func_name,
@ -2218,8 +2222,7 @@ class GitCommands:
def submodule_status(self, working_directory: str) -> List[Dict[str, str]]: def submodule_status(self, working_directory: str) -> List[Dict[str, str]]:
""" """
Gets the status of all submodules recursively and parses the output. Gets the status of all submodules recursively and parses the output.
Output format: ' ' (up-to-date), '-' (not initialized), '+' (commit mismatch), 'U' (conflict) Handles multiple output formats from the 'git submodule status' command.
Example line: '-b82312... gitsync_tool/logging_setup (unspecified)'
""" """
func_name = "submodule_status" func_name = "submodule_status"
log_handler.log_debug(f"Getting submodule status in '{working_directory}'", func_name=func_name) 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) result = self.log_and_execute(cmd, working_directory, check=True, log_output_level=logging.DEBUG)
submodules = [] submodules = []
# Regex to parse the status line # This regex is now more robust and handles two main formats:
status_regex = re.compile(r"^\s*([ U+-])([0-9a-fA-F]+)\s(.*?)\s\((.*?)\)\s*$") # 1. [U+- ]<commit> <path> (<description>) <- Status character is present
# 2. <commit> <path> (<description>) <- 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(): for line in result.stdout.strip().splitlines():
line = line.strip() line = line.strip()
@ -2238,11 +2248,15 @@ class GitCommands:
match = status_regex.match(line) match = status_regex.match(line)
if match: if match:
status_char, commit, path, description = match.groups() 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({ submodules.append({
"status_char": status_char, "status_char": final_status_char,
"commit": commit, "commit": commit.strip(),
"path": path.strip(), "path": path.strip(),
"description": description.strip(), "description": (description or "N/A").strip(),
}) })
else: else:
log_handler.log_warning(f"Could not parse submodule status line: '{line}'", func_name=func_name) log_handler.log_warning(f"Could not parse submodule status line: '{line}'", func_name=func_name)
@ -2274,3 +2288,207 @@ class GitCommands:
# Use -f to force deinit even with local changes # Use -f to force deinit even with local changes
cmd = ["git", "submodule", "deinit", "-f", "--", path] cmd = ["git", "submodule", "deinit", "-f", "--", path]
self.log_and_execute(cmd, working_directory, check=True) 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

View File

@ -2,6 +2,9 @@
import os import os
import datetime import datetime
import shutil
import time
import stat
from typing import Tuple, List, Optional from typing import Tuple, List, Optional
# Import using absolute path from the package # Import using absolute path from the package
@ -70,74 +73,75 @@ class SubmoduleHandler:
""" """
Initializes, fetches latest, and updates all submodules. Initializes, fetches latest, and updates all submodules.
If any submodule commit pointer changes, it creates a commit in the parent repo. 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: Args:
repo_path: The path to the parent repository. repo_path: The path to the parent repository.
Returns: Returns:
A tuple (success, message, commit_was_made). A tuple (success, message, commit_was_made).
Raises:
GitCommandError: If the submodule update command fails.
""" """
func_name = "execute_sync_all_submodules" func_name = "execute_sync_all_submodules"
log_handler.log_info(f"Executing sync for all submodules in '{repo_path}'", func_name=func_name) log_handler.log_info(f"Executing sync for all submodules in '{repo_path}'", func_name=func_name)
try: # 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) # Step 1: Get the state of submodules BEFORE the update
log_handler.log_info("Running 'git submodule update --init --remote --merge'...", func_name=func_name) log_handler.log_debug("Getting pre-sync submodule status...", func_name=func_name)
self.git_commands.submodule_update( status_before = {s["path"]: s["commit"] for s in self.git_commands.submodule_status(repo_path)}
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 # Step 2: Perform the update. This call will RAISE GitCommandError on failure.
log_handler.log_debug("Getting post-sync submodule status...", func_name=func_name) log_handler.log_info("Running 'git submodule update --init --remote --merge'...", func_name=func_name)
status_after = {s["path"]: s["commit"] for s in self.git_commands.submodule_status(repo_path)} 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 4: Compare states to see if anything changed # Step 3: Get the state AFTER the update
changed_submodules = [] log_handler.log_debug("Getting post-sync submodule status...", func_name=func_name)
for path, new_commit in status_after.items(): status_after = {s["path"]: s["commit"] for s in self.git_commands.submodule_status(repo_path)}
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 # Step 4: Compare states
if not changed_submodules: changed_submodules = []
msg = "All submodules are already up-to-date." for path, new_commit in status_after.items():
log_handler.log_info(msg, func_name=func_name) old_commit = status_before.get(path)
return True, msg, False if old_commit != new_commit:
changed_submodules.append((path, old_commit, new_commit))
log_handler.log_info(f"Found {len(changed_submodules)} updated submodules. Staging changes...", func_name=func_name) # 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
commit_body_lines = [] log_handler.log_info(f"Found {len(changed_submodules)} updated submodules. Staging changes...", func_name=func_name)
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) 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]}")
log_handler.log_info(f"Committing submodule updates with message:\n{commit_message}", func_name=func_name) commit_message = "Chore: Sync submodules\n\n" + "\n".join(commit_body_lines)
commit_made = self.git_commands.git_commit(repo_path, commit_message)
if commit_made: log_handler.log_info(f"Committing submodule updates with message:\n{commit_message}", func_name=func_name)
msg = f"Successfully synced {len(changed_submodules)} submodule(s) and created commit." commit_made = self.git_commands.git_commit(repo_path, commit_message, stage_all_first=False)
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: if commit_made:
log_handler.log_error(f"Failed to sync submodules: {e}", func_name=func_name) msg = f"Successfully synced {len(changed_submodules)} submodule(s) and created commit."
return False, f"Failed to sync submodules: {e}", False 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]: def execute_init_missing_submodules(self, repo_path: str) -> Tuple[bool, str]:
""" """
@ -213,3 +217,143 @@ class SubmoduleHandler:
except (GitCommandError, ValueError) as e: except (GitCommandError, ValueError) as e:
log_handler.log_error(f"Failed to remove submodule '{submodule_path}': {e}", func_name=func_name) log_handler.log_error(f"Failed to remove submodule '{submodule_path}': {e}", func_name=func_name)
return False, f"Failed to remove submodule: {e}" 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

View File

@ -4,7 +4,7 @@ import tkinter as tk
from tkinter import ttk, messagebox, simpledialog, filedialog from tkinter import ttk, messagebox, simpledialog, filedialog
import os import os
import re # Per validazione nomi branch/tag 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) --- # --- 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 --- # --- END OF FILE gitsync_tool/gui/dialogs.py ---

View File

@ -26,6 +26,7 @@ class AutomationTab(ttk.Frame):
# Store callbacks # Store callbacks
self.update_gitea_wiki_callback = kwargs.get('update_gitea_wiki_cb') 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.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) # Get a reference to the main frame to access shared variables (like remote_url_var)
self.main_frame = self.master.master self.main_frame = self.master.master
@ -90,6 +91,31 @@ class AutomationTab(ttk.Frame):
"This action can rewrite the entire repository history." "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: def set_action_widgets_state(self, state: str) -> None:
""" """
Sets the state of all action widgets in this tab. Sets the state of all action widgets in this tab.
@ -109,6 +135,7 @@ class AutomationTab(ttk.Frame):
widgets = [ widgets = [
self.update_wiki_button, self.update_wiki_button,
self.analyze_history_button, self.analyze_history_button,
self.clean_submodule_button,
] ]
for widget in widgets: for widget in widgets:

View File

@ -1,10 +1,12 @@
# --- FILE: gitsync_tool/logic/automation_handler.py --- # --- FILE: gitsync_tool/logic/automation_handler.py ---
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from tkinter import simpledialog
from gitutility.async_tasks import async_workers from gitutility.async_tasks import async_workers
from gitutility.logging_setup import log_handler from gitutility.logging_setup import log_handler
from gitutility.config.config_manager import DEFAULT_REMOTE_NAME from gitutility.config.config_manager import DEFAULT_REMOTE_NAME
from gitutility.gui.dialogs import SelectSubmoduleDialog
# Forward reference for type hinting # Forward reference for type hinting
if TYPE_CHECKING: if TYPE_CHECKING:
@ -78,3 +80,50 @@ class AutomationHandler:
"repo_path": svn_path, "repo_path": svn_path,
}, },
) )
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}")

View File

@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Optional, Tuple
from gitutility.async_tasks import async_workers from gitutility.async_tasks import async_workers
from gitutility.logging_setup import log_handler from gitutility.logging_setup import log_handler
from gitutility.gui.dialogs import AddSubmoduleDialog
# Forward reference for type hinting to avoid circular import # Forward reference for type hinting to avoid circular import
if TYPE_CHECKING: if TYPE_CHECKING:
@ -51,8 +52,11 @@ class SubmoduleLogicHandler:
self.main_frame.show_error("Action Failed", "Repository is not ready.") self.main_frame.show_error("Action Failed", "Repository is not ready.")
return return
# Use the dialog method on MainFrame to get user input # --- MODIFICA: Usa il nuovo dialogo personalizzato ---
details: Optional[Tuple[str, str]] = self.main_frame.ask_new_submodule_details() dialog = AddSubmoduleDialog(self.app.master)
details = dialog.result
# ---------------------------------------------------
if not details: if not details:
self.main_frame.update_status_bar("Add submodule cancelled.") self.main_frame.update_status_bar("Add submodule cancelled.")
return return
@ -60,7 +64,7 @@ class SubmoduleLogicHandler:
submodule_url, submodule_path = details submodule_url, submodule_path = details
log_handler.log_info(f"Attempting to add submodule '{submodule_url}' at path '{submodule_path}'", func_name=func_name) 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( self.app._start_async_operation(
worker_func=async_workers.run_add_submodule_async, worker_func=async_workers.run_add_submodule_async,
args_tuple=args, args_tuple=args,
@ -126,3 +130,5 @@ class SubmoduleLogicHandler:
args_tuple=args, args_tuple=args,
context_dict={"context": "init_submodules", "status_msg": "Initializing missing submodules..."}, context_dict={"context": "init_submodules", "status_msg": "Initializing missing submodules..."},
) )