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,
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")

View File

@ -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,12 +453,74 @@ 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"):
return True, True
elif result_data.get("status") == "error":
self.main_frame.show_error("Sync Submodules Error", f"Failed:\n{result_data.get('message')}")
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 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":
return True, True
@ -470,6 +535,16 @@ class AsyncResultHandler:
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.")
if status == "error":

View File

@ -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}")
@ -1148,3 +1159,43 @@ def run_init_submodules_async(
finally:
results_queue.put(result_payload)
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,6 +1273,10 @@ 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"
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(
f"Getting content for file='{git_file_path}' at ref='{git_ref}' (using '{ref_path_arg}') in '{working_directory}'",
@ -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+- ]<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():
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)
@ -2274,3 +2288,207 @@ class GitCommands:
# Use -f to force deinit even with local changes
cmd = ["git", "submodule", "deinit", "-f", "--", path]
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 datetime
import shutil
import time
import stat
from typing import Tuple, List, Optional
# Import using absolute path from the package
@ -70,22 +73,27 @@ 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:
# 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 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,
@ -94,20 +102,20 @@ class SubmoduleHandler:
recursive=True,
merge=True
)
log_handler.log_info("Submodule update command finished.", func_name=func_name)
log_handler.log_info("Submodule update command finished successfully.", func_name=func_name)
# Step 3: Get the state AFTER the update
log_handler.log_debug("Getting post-sync submodule status...", func_name=func_name)
status_after = {s["path"]: s["commit"] for s in self.git_commands.submodule_status(repo_path)}
# Step 4: Compare states to see if anything changed
# 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 them and commit in the parent repo
# 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)
@ -118,13 +126,12 @@ class SubmoduleHandler:
commit_body_lines = []
for path, old_commit, new_commit in changed_submodules:
self.git_commands.add_file(repo_path, path)
log_handler.log_debug(f"Staged submodule path: '{path}'", func_name=func_name)
commit_body_lines.append(f"- Update {path} from {old_commit[:7]} to {new_commit[:7]}")
commit_message = "Chore: Sync submodules\n\n" + "\n".join(commit_body_lines)
log_handler.log_info(f"Committing submodule updates with message:\n{commit_message}", func_name=func_name)
commit_made = self.git_commands.git_commit(repo_path, commit_message)
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."
@ -133,11 +140,8 @@ class SubmoduleHandler:
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
# 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]:
"""
@ -213,3 +217,143 @@ 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}"
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
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 ---

View File

@ -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
@ -90,6 +91,31 @@ class AutomationTab(ttk.Frame):
"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:
"""
Sets the state of all action widgets in this tab.
@ -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:

View File

@ -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:
@ -78,3 +80,50 @@ class AutomationHandler:
"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.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,
@ -126,3 +130,5 @@ class SubmoduleLogicHandler:
args_tuple=args,
context_dict={"context": "init_submodules", "status_msg": "Initializing missing submodules..."},
)