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