# GitUtility.py import os import sys # import shutil # Not needed anymore import datetime import tkinter as tk from tkinter import messagebox import logging # import zipfile # Not needed anymore # Import application modules from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR from git_commands import GitCommands, GitCommandError from logger_config import setup_logger # Import GUI classes from gui import MainFrame, GitignoreEditorWindow, CreateTagDialog, CreateBranchDialog # Import Handler classes from backup_handler import BackupHandler from profile_handler import ProfileHandler from action_handler import ActionHandler class GitSvnSyncApp: """ Main application class: Coordinates GUI, configuration, and actions. Delegates logic to specific handler classes. """ def __init__(self, master): """ Initializes the GitSvnSyncApp. Args: master (tk.Tk): The main Tkinter root window. """ self.master = master master.title("Git SVN Sync Tool") master.protocol("WM_DELETE_WINDOW", self.on_closing) # --- Early Logger Setup --- logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) self.logger = logging.getLogger("GitSvnSyncApp") # --- Initialize Core Components --- try: self.config_manager = ConfigManager(self.logger) self.git_commands = GitCommands(self.logger) # Initialize Handlers self.profile_handler = ProfileHandler(self.logger, self.config_manager) self.backup_handler = BackupHandler(self.logger) self.action_handler = ActionHandler( self.logger, self.git_commands, self.backup_handler ) except Exception as e: self.logger.critical(f"Failed component init: {e}", exc_info=True) self.show_fatal_error(f"Initialization Error:\n{e}\nApp cannot start.") master.destroy() return # --- Create GUI Main Frame --- try: self.main_frame = MainFrame( master, load_profile_settings_cb=self.load_profile_settings, # browse_folder_cb REMOVED - Handled within MainFrame now update_svn_status_cb=self.update_svn_status_indicator, # Core Action Callbacks prepare_svn_for_git_cb=self.ui_prepare_svn, create_git_bundle_cb=self.ui_create_bundle, fetch_from_git_bundle_cb=self.ui_fetch_bundle, manual_backup_cb=self.ui_manual_backup, # Profile/Settings Callbacks add_profile_cb=self.ui_add_profile, remove_profile_cb=self.ui_remove_profile, save_profile_cb=self.ui_save_settings, # Commit/Gitignore Callbacks manual_commit_cb=self.ui_manual_commit, open_gitignore_editor_cb=self.open_gitignore_editor, # Tag Callbacks refresh_tags_cb=self.refresh_tag_list, create_tag_cb=self.ui_create_tag, checkout_tag_cb=self.ui_checkout_tag, delete_tag_cb=self.ui_delete_tag, # Pass new callback # Branch Callbacks refresh_branches_cb=self.refresh_branch_list, create_branch_cb=self.ui_create_branch, switch_branch_cb=self.ui_switch_branch, delete_branch_cb=self.ui_delete_branch, # Pass instances/data if needed by GUI config_manager_instance=self.config_manager, profile_sections_list=self.profile_handler.get_profile_list() ) except Exception as e: self.logger.critical(f"Failed init MainFrame: {e}", exc_info=True) self.show_fatal_error(f"GUI Error:\n{e}\nApp cannot start.") master.destroy() return # --- Enhanced Logger Setup --- self.logger = setup_logger(self.main_frame.log_text) # Ensure all components use the final logger self.config_manager.logger = self.logger self.git_commands.logger = self.logger self.profile_handler.logger = self.logger self.backup_handler.logger = self.logger self.action_handler.logger = self.logger # --- Initial Application State --- self.logger.info("Application initializing...") initial_profile = self.main_frame.profile_var.get() if initial_profile: self.logger.debug(f"Initial profile: '{initial_profile}'. Loading...") # Load settings (called automatically by trace on profile_var) else: self.logger.warning("No profile selected on startup.") self._clear_and_disable_fields() # Set initial disabled state self.logger.info("Application started successfully.") def on_closing(self): """Handles window close event.""" self.logger.info("Application closing.") self.master.destroy() # --- Helper methods to get/validate paths from GUI --- def _get_and_validate_svn_path(self, operation_name="Operation"): """Retrieves and validates the SVN path from the GUI.""" if not hasattr(self, 'main_frame'): self.logger.error(f"{operation_name}: GUI component unavailable.") return None if not hasattr(self.main_frame, 'svn_path_entry'): self.logger.error(f"{operation_name}: SVN path widget missing.") if hasattr(self, 'main_frame'): self.main_frame.show_error("Internal Error", "SVN Path widget missing.") return None svn_path_str = self.main_frame.svn_path_entry.get() svn_path_str = svn_path_str.strip() if not svn_path_str: self.logger.error(f"{operation_name}: SVN Path is empty.") self.main_frame.show_error("Input Error", "SVN Path cannot be empty.") return None abs_path = os.path.abspath(svn_path_str) if not os.path.isdir(abs_path): self.logger.error(f"{operation_name}: Invalid directory path: {abs_path}") self.main_frame.show_error("Input Error", f"Invalid SVN path:\n{abs_path}") return None self.logger.debug(f"{operation_name}: Validated SVN path: {abs_path}") return abs_path def _get_and_validate_usb_path(self, operation_name="Operation"): """Retrieves and validates the USB/Bundle Target path from the GUI.""" if not hasattr(self, 'main_frame'): self.logger.error(f"{operation_name}: GUI component unavailable.") return None if not hasattr(self.main_frame, 'usb_path_entry'): self.logger.error(f"{operation_name}: USB path widget missing.") if hasattr(self, 'main_frame'): self.main_frame.show_error("Internal Error", "USB Path widget missing.") return None usb_path_str = self.main_frame.usb_path_entry.get() usb_path_str = usb_path_str.strip() if not usb_path_str: self.logger.error(f"{operation_name}: Bundle Target Dir path empty.") self.main_frame.show_error("Input Error", "Bundle Target Dir empty.") return None abs_path = os.path.abspath(usb_path_str) if not os.path.isdir(abs_path): self.logger.error(f"{operation_name}: Invalid Bundle Target: {abs_path}") self.main_frame.show_error("Input Error", f"Invalid Bundle Target:\n{abs_path}") return None self.logger.debug(f"{operation_name}: Validated Bundle Target path: {abs_path}") return abs_path # --- ADDED: Helper to parse exclusions directly here --- def _parse_exclusions(self, profile_name): """ Parses exclusion string from config for the given profile. Needed here because ActionHandler might not have direct ConfigManager access, and exclusions are needed for backup steps within actions. Args: profile_name (str): The name of the profile. Returns: tuple: (set of excluded extensions (lowercase, starting with '.'), set of excluded base directory names (lowercase)) Raises: ValueError: If exclusion string parsing fails. """ try: # Get exclusion string from config exclude_str = self.config_manager.get_profile_option( profile_name, "backup_exclude_extensions", fallback="" ) excluded_extensions = set() # Define standard directories to always exclude (lowercase for comparison) excluded_dirs_base = {".git", ".svn"} if exclude_str: raw_extensions = exclude_str.split(',') for ext in raw_extensions: clean_ext = ext.strip().lower() if not clean_ext: continue # Skip empty parts # Ensure extension starts with a dot if not clean_ext.startswith('.'): clean_ext = '.' + clean_ext excluded_extensions.add(clean_ext) self.logger.debug( f"Parsed Exclusions '{profile_name}' - " f"Ext: {excluded_extensions}, Dirs: {excluded_dirs_base}" ) return excluded_extensions, excluded_dirs_base except Exception as e: self.logger.error(f"Error parsing exclusions for '{profile_name}': {e}", exc_info=True) # Raise a specific error to indicate parsing failure raise ValueError(f"Could not parse backup exclusions: {e}") from e # --- Profile Handling Wrappers --- def load_profile_settings(self, profile_name): """Loads profile settings into GUI when profile selection changes.""" self.logger.info(f"UI Request: Load profile '{profile_name}'") if not profile_name: self._clear_and_disable_fields() return # Delegate loading to ProfileHandler profile_data = self.profile_handler.load_profile_data(profile_name) if not profile_data: # Handler logs error, show message and clear UI self.main_frame.show_error("Load Error", f"Could not load '{profile_name}'.") self._clear_and_disable_fields() return # Update GUI fields with loaded data if frame exists if hasattr(self, 'main_frame'): mf = self.main_frame # Alias # Update all widgets based on loaded data mf.svn_path_entry.delete(0, tk.END) mf.svn_path_entry.insert(0, profile_data.get("svn_working_copy_path", "")) mf.usb_path_entry.delete(0, tk.END) mf.usb_path_entry.insert(0, profile_data.get("usb_drive_path", "")) mf.bundle_name_entry.delete(0, tk.END) mf.bundle_name_entry.insert(0, profile_data.get("bundle_name", "")) mf.bundle_updated_name_entry.delete(0, tk.END) mf.bundle_updated_name_entry.insert(0, profile_data.get("bundle_name_updated", "")) mf.autocommit_var.set(profile_data.get("autocommit", False)) # Bool mf.commit_message_var.set(profile_data.get("commit_message", "")) mf.autobackup_var.set(profile_data.get("autobackup", False)) # Bool mf.backup_dir_var.set(profile_data.get("backup_dir", DEFAULT_BACKUP_DIR)) mf.backup_exclude_extensions_var.set( profile_data.get("backup_exclude_extensions", ".log,.tmp") ) mf.toggle_backup_dir() # Update backup dir entry state # Update status indicators and enable/disable buttons based on path svn_path = profile_data.get("svn_working_copy_path", "") self.update_svn_status_indicator(svn_path) # Updates state widgets self._enable_function_buttons() # Enable general action buttons # Refresh tag and branch lists if repo ready repo_ready = (svn_path and os.path.isdir(svn_path) and os.path.exists(os.path.join(svn_path, ".git"))) if repo_ready: self.refresh_tag_list() self.refresh_branch_list() self.update_current_branch_display() # Update display label else: # Clear lists if repo not ready mf.update_tag_list([]) mf.update_branch_list([]) mf.set_current_branch_display("") # Reset display label self.logger.info(f"Settings loaded successfully for '{profile_name}'.") else: self.logger.error("Cannot load settings: Main frame missing.") def ui_save_settings(self): """Callback for the 'Save Settings' button.""" self.logger.debug("UI Request: Save Settings button clicked.") profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Save Error", "No profile selected.") return False # Indicate failure to caller if needed # Gather data from GUI current_data = self._get_data_from_gui() if current_data is None: # Check if reading GUI failed self.main_frame.show_error("Internal Error", "Could not read GUI data.") return False # Delegate saving to ProfileHandler success = self.profile_handler.save_profile_data(profile, current_data) if success: self.main_frame.show_info("Saved", f"Settings saved for '{profile}'.") return True else: # Error message likely shown by handler/save method # self.main_frame.show_error("Save Error", f"Failed save settings.") return False def _get_data_from_gui(self): """Helper to gather current settings from GUI widgets into a dict.""" if not hasattr(self, 'main_frame'): self.logger.error("Cannot get GUI data: Main frame missing.") return None # Indicate failure mf = self.main_frame # Read values from all relevant widgets/variables data = {} data["svn_working_copy_path"] = mf.svn_path_entry.get() data["usb_drive_path"] = mf.usb_path_entry.get() data["bundle_name"] = mf.bundle_name_entry.get() data["bundle_name_updated"] = mf.bundle_updated_name_entry.get() data["autocommit"] = mf.autocommit_var.get() # Gets boolean data["commit_message"] = mf.commit_message_var.get() data["autobackup"] = mf.autobackup_var.get() # Gets boolean data["backup_dir"] = mf.backup_dir_var.get() data["backup_exclude_extensions"] = mf.backup_exclude_extensions_var.get() return data def ui_add_profile(self): """Callback for the 'Add Profile' button.""" self.logger.debug("UI Request: Add Profile.") new_name = self.main_frame.ask_new_profile_name() if not new_name: self.logger.info("Add profile cancelled."); return new_name = new_name.strip() if not new_name: self.main_frame.show_error("Error", "Profile name empty."); return # Delegate adding logic success = self.profile_handler.add_new_profile(new_name) if success: sections = self.profile_handler.get_profile_list() self.main_frame.update_profile_dropdown(sections) self.main_frame.profile_var.set(new_name) # Triggers load self.main_frame.show_info("Profile Added", f"Profile '{new_name}' created.") else: self.main_frame.show_error("Error", f"Could not add profile '{new_name}'.") def ui_remove_profile(self): """Callback for the 'Remove Profile' button.""" self.logger.debug("UI Request: Remove Profile.") profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Error", "No profile selected."); return if profile == DEFAULT_PROFILE: self.main_frame.show_error("Error", f"Cannot remove '{DEFAULT_PROFILE}'."); return # Confirm with user msg = f"Remove profile '{profile}'?" if self.main_frame.ask_yes_no("Remove Profile", msg): # Delegate removal success = self.profile_handler.remove_existing_profile(profile) if success: sections = self.profile_handler.get_profile_list() self.main_frame.update_profile_dropdown(sections) # Triggers load self.main_frame.show_info("Removed", f"Profile '{profile}' removed.") else: self.main_frame.show_error("Error", f"Failed to remove '{profile}'.") else: self.logger.info("Removal cancelled.") # --- GUI Interaction Wrappers --- def browse_folder(self, entry_widget): """Opens folder dialog to update an entry widget.""" self.logger.debug("Browse folder requested.") current = entry_widget.get() initial = current if os.path.isdir(current) else os.path.expanduser("~") directory = filedialog.askdirectory(initialdir=initial, title="Select Directory", parent=self.master) if directory: self.logger.debug(f"Selected: {directory}") entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) # Trigger status update if SVN path changed if entry_widget == self.main_frame.svn_path_entry: self.update_svn_status_indicator(directory) else: self.logger.debug("Browse cancelled.") def update_svn_status_indicator(self, svn_path): """Checks repo status and updates all dependent GUI widget states.""" is_valid = bool(svn_path and os.path.isdir(svn_path)) is_ready = is_valid and os.path.exists(os.path.join(svn_path, ".git")) self.logger.debug(f"Updating status indicators for '{svn_path}'. " f"Valid:{is_valid}, Ready:{is_ready}") if hasattr(self, 'main_frame'): mf = self.main_frame # Alias # Update indicator & Prepare button via GUI method mf.update_svn_indicator(is_ready) # Determine states for other dependent widgets gitignore_state = tk.NORMAL if is_valid else tk.DISABLED repo_ready_state = tk.NORMAL if is_ready else tk.DISABLED # Apply states to relevant widgets if they exist widget_states = { 'edit_gitignore_button': gitignore_state, 'commit_message_entry': repo_ready_state, 'autocommit_checkbox': repo_ready_state, 'commit_button': repo_ready_state, 'refresh_branches_button': repo_ready_state, 'create_branch_button': repo_ready_state, 'switch_branch_button': repo_ready_state, 'delete_branch_button': repo_ready_state, 'refresh_tags_button': repo_ready_state, 'create_tag_button': repo_ready_state, 'checkout_tag_button': repo_ready_state, 'delete_tag_button': repo_ready_state, } for widget_name, state in widget_states.items(): widget = getattr(mf, widget_name, None) if widget: # Ensure config method is called safely try: widget.config(state=state) except tk.TclError as e: self.logger.warning(f"TclError configuring {widget_name}: {e}") # Update current branch display if repo not ready if not is_ready: mf.set_current_branch_display("") def open_gitignore_editor(self): """Opens the modal editor window for .gitignore.""" self.logger.info("--- Action Triggered: Edit .gitignore ---") svn_path = self._get_and_validate_svn_path("Edit .gitignore") if not svn_path: return gitignore_path = os.path.join(svn_path, ".gitignore") self.logger.debug(f"Target .gitignore path: {gitignore_path}") try: # Create and run the modal editor window editor = GitignoreEditorWindow(self.master, gitignore_path, self.logger) # Execution blocks here until editor is closed self.logger.debug("Gitignore editor finished.") except Exception as e: self.logger.exception(f"Error opening .gitignore editor: {e}") self.main_frame.show_error("Editor Error", f"Could not open editor:\n{e}") # --- Core Action Wrappers (GUI Callbacks) --- def ui_prepare_svn(self): """Callback for 'Prepare SVN Repo' button.""" self.logger.info("--- Action Triggered: Prepare SVN Repo ---") svn_path = self._get_and_validate_svn_path("Prepare SVN") if not svn_path: return # Save settings before action # Use ui_save_settings which returns True/False if not self.ui_save_settings(): self.logger.warning("Prepare SVN: Failed to save settings first.") # Ask user if they want to continue? # if not self.main_frame.ask_yes_no("Warning", "Could not save settings.\nContinue anyway?"): # return # Delegate execution to ActionHandler try: self.action_handler.execute_prepare_repo(svn_path) self.main_frame.show_info("Success", "Repository prepared.") # Update GUI state after successful preparation self.update_svn_status_indicator(svn_path) except ValueError as e: # Catch specific "already prepared" error self.main_frame.show_info("Info", str(e)) self.update_svn_status_indicator(svn_path) # Ensure UI reflects state except (GitCommandError, IOError) as e: self.main_frame.show_error("Error", f"Failed prepare:\n{e}") self.update_svn_status_indicator(svn_path) # Update state after failure except Exception as e: self.logger.exception(f"Unexpected prepare error: {e}") self.main_frame.show_error("Error", f"Unexpected error:\n{e}") self.update_svn_status_indicator(svn_path) def ui_create_bundle(self): """Callback for 'Create Bundle' button.""" self.logger.info("--- Action Triggered: Create Git Bundle ---") profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Error", "No profile selected.") return # Validate inputs svn_path = self._get_and_validate_svn_path("Create Bundle") if not svn_path: return usb_path = self._get_and_validate_usb_path("Create Bundle") if not usb_path: return bundle_name = self.main_frame.bundle_name_entry.get().strip() if not bundle_name: self.main_frame.show_error("Input Error", "Bundle name empty.") return # Ensure .bundle extension if not bundle_name.lower().endswith(".bundle"): bundle_name += ".bundle" mf = self.main_frame # Alias mf.bundle_name_entry.delete(0, tk.END) mf.bundle_name_entry.insert(0, bundle_name) bundle_full_path = os.path.join(usb_path, bundle_name) # Get settings needed by action handler settings = self._get_data_from_gui() if not settings: return backup_needed = settings.get("autobackup", False) commit_needed = settings.get("autocommit", False) commit_msg = settings.get("commit_message", "") backup_dir = settings.get("backup_dir", "") try: # Parse exclusions needed for backup step excluded_ext, excluded_dir = self._parse_exclusions(profile) except ValueError as e: self.main_frame.show_error("Config Error", str(e)) return # Save settings before action if not self.ui_save_settings(): self.logger.warning("Create Bundle: Could not save settings.") # Ask user? # Delegate execution to ActionHandler try: created_path = self.action_handler.execute_create_bundle( svn_path, bundle_full_path, profile, backup_needed, backup_dir, commit_needed, commit_msg, excluded_ext, excluded_dir ) # Show feedback based on result if created_path: self.main_frame.show_info("Success", f"Bundle created:\n{created_path}") else: # Non-fatal issue (e.g., empty bundle) self.main_frame.show_warning("Info", "Bundle empty or not created.") except Exception as e: # Handle errors raised by action_handler self.logger.error(f"Bundle process error: {e}", exc_info=True) self.main_frame.show_error("Error", f"Failed create bundle:\n{e}") def ui_fetch_bundle(self): """Callback for 'Fetch Bundle' button.""" self.logger.info("--- Action Triggered: Fetch from Git Bundle ---") profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Error", "No profile selected.") return # Validate inputs svn_path = self._get_and_validate_svn_path("Fetch Bundle") if not svn_path: return usb_path = self._get_and_validate_usb_path("Fetch Bundle") if not usb_path: return bundle_name = self.main_frame.bundle_updated_name_entry.get().strip() if not bundle_name: self.main_frame.show_error("Input Error", "Fetch bundle name empty.") return bundle_full_path = os.path.join(usb_path, bundle_name) if not os.path.isfile(bundle_full_path): self.main_frame.show_error("Error", f"Bundle not found:\n{bundle_full_path}") return # Get settings needed by action handler settings = self._get_data_from_gui() if not settings: return backup_needed = settings.get("autobackup", False) backup_dir = settings.get("backup_dir", "") try: excluded_ext, excluded_dir = self._parse_exclusions(profile) except ValueError as e: self.main_frame.show_error("Config Error", str(e)) return # Save settings before action if not self.ui_save_settings(): self.logger.warning("Fetch Bundle: Could not save settings.") # Ask user? # Delegate execution to ActionHandler try: self.action_handler.execute_fetch_bundle( svn_path, bundle_full_path, profile, backup_needed, backup_dir, excluded_ext, excluded_dir ) # Show generic success, conflicts handled by error message below self.main_frame.show_info("Fetch Complete", f"Fetch complete.\nCheck logs for status.") # Refresh state after fetch completes successfully self.refresh_branch_list() self.update_current_branch_display() self.refresh_tag_list() except GitCommandError as e: # Handle specific Git errors like merge conflicts self.logger.error(f"Fetch/merge error: {e}", exc_info=False) if "merge conflict" in str(e).lower(): self.main_frame.show_error( "Merge Conflict", f"Conflict occurred.\nResolve manually in:\n{svn_path}\nThen commit." ) else: self.main_frame.show_error("Error", f"Fetch/Merge Failed:\n{e}") except Exception as e: # Handle other errors (backup, unexpected) self.logger.error(f"Fetch process error: {e}", exc_info=True) self.main_frame.show_error("Error", f"Fetch failed:\n{e}") def ui_manual_backup(self): """Callback for 'Backup Now' button.""" self.logger.info("--- Action Triggered: Manual Backup ---") profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Error", "No profile selected.") return # Validate paths svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})") if not svn_path: return backup_dir = self.main_frame.backup_dir_var.get().strip() if not backup_dir: self.main_frame.show_error("Backup Error", "Backup directory empty.") return # Check/create backup dir if not os.path.isdir(backup_dir): create_q = f"Create directory:\n{backup_dir}?" if self.main_frame.ask_yes_no("Create Directory?", create_q): try: os.makedirs(backup_dir, exist_ok=True) except OSError as e: self.main_frame.show_error("Error", f"Cannot create dir:\n{e}") return # Stop if cannot create dir else: self.logger.info("User cancelled backup dir creation.") return # Stop if user cancels creation # Parse exclusions needed for backup handler try: excluded_ext, excluded_dir = self._parse_exclusions(profile) except ValueError as e: self.main_frame.show_error("Config Error", str(e)) return # Save settings first (important for exclusions and backup dir) if not self.ui_save_settings(): confirm_q = "Could not save settings.\nContinue backup anyway?" if not self.main_frame.ask_yes_no("Warning", confirm_q): self.logger.warning("Manual backup aborted by user.") return # Delegate backup creation to BackupHandler try: backup_path = self.backup_handler.create_zip_backup( svn_path, backup_dir, profile, excluded_ext, excluded_dir ) # Show success message if path is returned if backup_path: self.main_frame.show_info( "Backup Complete", f"Backup created:\n{backup_path}" ) # else: BackupHandler logs issues, maybe return specific status? except Exception as e: # Handle any exception raised by backup handler self.logger.error(f"Manual backup failed: {e}", exc_info=True) self.main_frame.show_error("Backup Error", f"Failed:\n{e}") def ui_manual_commit(self): """Callback for the 'Commit Changes' button.""" self.logger.info("--- Action Triggered: Manual Commit ---") svn_path = self._get_and_validate_svn_path("Manual Commit") if not svn_path: return # Get commit message from the GUI entry commit_msg = self.main_frame.commit_message_var.get().strip() if not commit_msg: self.main_frame.show_error("Commit Error", "Commit message empty.") return # Save settings first? Optional, but saves the message if user typed it. if not self.ui_save_settings(): self.logger.warning("Manual Commit: Could not save settings.") # Ask user? # Delegate commit execution to ActionHandler try: commit_made = self.action_handler.execute_manual_commit( svn_path, commit_msg ) if commit_made: self.main_frame.show_info("Success", "Changes committed.") # Clear message field after successful commit? Optional. # self.main_frame.commit_message_var.set("") else: # git_commit already logged "nothing to commit" self.main_frame.show_info("Info", "No changes to commit.") except (GitCommandError, ValueError) as e: self.logger.error(f"Manual commit failed: {e}") self.main_frame.show_error("Commit Error", f"Failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected commit error: {e}") self.main_frame.show_error("Error", f"Unexpected commit error:\n{e}") # --- Tag Management Callbacks --- def refresh_tag_list(self): """Refreshes tag list in GUI.""" self.logger.info("--- Action: Refresh Tag List ---") svn_path = self._get_and_validate_svn_path("Refresh Tags") # Repo must be ready to list tags repo_ready = svn_path and os.path.exists(os.path.join(svn_path, ".git")) if not repo_ready: if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([]) # Clear list return # Fetch tags and update GUI try: # list_tags returns list of tuples (name, subject) tags_data = self.git_commands.list_tags(svn_path) if hasattr(self, 'main_frame'): self.main_frame.update_tag_list(tags_data) self.logger.info(f"Tag list updated ({len(tags_data)} tags).") except Exception as e: self.logger.error(f"Failed refresh tags: {e}", exc_info=True) self.main_frame.show_error("Error", f"Could not refresh tags:\n{e}") if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([]) def ui_create_tag(self): """Callback for 'Create Tag' button.""" self.logger.info("--- Action Triggered: Create Tag ---") svn_path = self._get_and_validate_svn_path("Create Tag") if not svn_path: return profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Error", "No profile selected.") return # Get commit message from GUI (needed by action handler for pre-commit) commit_msg = self.main_frame.commit_message_var.get().strip() # Save settings before action (saves commit message) if not self.ui_save_settings(): self.logger.warning("Create Tag: Could not save settings first.") # Ask user? # --- Open Dialog to get Tag Name and Tag Message --- self.logger.debug("Opening create tag dialog...") dialog = CreateTagDialog(self.master) # Parent is the main Tk window tag_info = dialog.result # Returns (tag_name, tag_message) or None if not tag_info: # User cancelled the dialog self.logger.info("Tag creation cancelled by user in dialog.") return tag_name, tag_message = tag_info self.logger.info(f"User provided tag: '{tag_name}', msg: '{tag_message}'") # --- Delegate Execution to ActionHandler --- try: # ActionHandler manages pre-commit logic based on commit_msg presence success = self.action_handler.execute_create_tag( svn_path, commit_msg, tag_name, tag_message ) # execute_create_tag raises exceptions on failure if success: # Should be true if no exception self.logger.info(f"Tag '{tag_name}' created successfully.") self.main_frame.show_info("Success", f"Tag '{tag_name}' created.") self.refresh_tag_list() # Update list after successful creation except ValueError as e: # Catch specific errors like "commit message required" self.logger.error(f"Tag creation validation failed: {e}") self.main_frame.show_error("Tag Error", str(e)) except GitCommandError as e: # Catch Git command errors (from commit or tag) self.logger.error(f"Tag creation failed (Git Error): {e}") self.main_frame.show_error("Tag Error", f"Git command failed:\n{e}") except Exception as e: # Catch unexpected errors self.logger.exception(f"Unexpected error creating tag: {e}") self.main_frame.show_error("Error", f"Unexpected error:\n{e}") def ui_checkout_tag(self): """Callback for 'Checkout Selected Tag' button.""" self.logger.info("--- Action Triggered: Checkout Tag ---") svn_path = self._get_and_validate_svn_path("Checkout Tag") if not svn_path: return selected_tag = self.main_frame.get_selected_tag() # Gets name if not selected_tag: self.main_frame.show_error("Selection Error", "Select a tag.") return self.logger.info(f"Attempting checkout for tag: {selected_tag}") # Confirmation dialog first msg = (f"Checkout tag '{selected_tag}'?\n\n" f"WARNINGS:\n- Files WILL BE OVERWRITTEN.\n- NO backup created.\n" f"- Enters 'detached HEAD' state.") if not self.main_frame.ask_yes_no("Confirm Checkout", msg): self.logger.info("Tag checkout cancelled."); return # Save settings before action? Optional. if not self.ui_save_settings(): self.logger.warning("Checkout Tag: Could not save profile settings.") # Delegate execution to ActionHandler try: success = self.action_handler.execute_checkout_tag(svn_path, selected_tag) if success: self.main_frame.show_info("Success", f"Checked out tag '{selected_tag}'.\n\nNOTE: In 'detached HEAD'.") # Update branch display after successful checkout self.update_current_branch_display() except ValueError as e: # Catch specific errors like "uncommitted changes" self.main_frame.show_error("Checkout Blocked", str(e)) except GitCommandError as e: # Catch Git command errors self.main_frame.show_error("Error", f"Could not checkout:\n{e}") except Exception as e: # Catch unexpected errors self.logger.exception(f"Unexpected checkout error: {e}") self.main_frame.show_error("Error", f"Unexpected checkout error:\n{e}") def ui_delete_tag(self): """Callback for 'Delete Selected Tag' button.""" self.logger.info("--- Action Triggered: Delete Tag ---") svn_path = self._get_and_validate_svn_path("Delete Tag") if not svn_path: return selected_tag = self.main_frame.get_selected_tag() # Gets name if not selected_tag: self.main_frame.show_error("Selection Error", "Select a tag to delete.") return # Confirmation dialog msg = f"Delete tag '{selected_tag}' permanently?\nCannot be easily undone." if not self.main_frame.ask_yes_no("Confirm Delete Tag", msg): self.logger.info("Tag deletion cancelled.") return self.logger.info(f"Attempting delete tag: {selected_tag}") # Delegate execution to ActionHandler try: success = self.action_handler.execute_delete_tag(svn_path, selected_tag) if success: self.main_frame.show_info("Success", f"Tag '{selected_tag}' deleted.") self.refresh_tag_list() # Update list # else: action_handler raises error on failure except (GitCommandError, ValueError) as e: # Handle known errors (not found, invalid name format?) self.main_frame.show_error("Error", f"Could not delete tag:\n{e}") except Exception as e: # Handle unexpected errors self.logger.exception(f"Unexpected error deleting tag: {e}") self.main_frame.show_error("Error", f"Unexpected error:\n{e}") # --- Branch Management Callbacks --- def refresh_branch_list(self): """Refreshes the branch list in the GUI.""" self.logger.info("--- Action: Refresh Branch List ---") svn_path = self._get_and_validate_svn_path("Refresh Branches") # Repo must be ready to list branches is_ready = svn_path and os.path.exists(os.path.join(svn_path, ".git")) if not is_ready: if hasattr(self, 'main_frame'): self.main_frame.update_branch_list([]) return # Fetch and update GUI try: # Assumes git_commands method exists and returns list of names branches = self.git_commands.list_branches(svn_path) if hasattr(self, 'main_frame'): # Update listbox and potentially current branch display implicitly self.main_frame.update_branch_list(branches) self.logger.info(f"Branch list updated ({len(branches)} branches).") except Exception as e: self.logger.error(f"Failed refresh branches: {e}", exc_info=True) self.main_frame.show_error("Error", f"Could not refresh branches:\n{e}") if hasattr(self, 'main_frame'): self.main_frame.update_branch_list([]) def update_current_branch_display(self): """Gets the current branch and updates the display label.""" self.logger.debug("Updating current branch display...") svn_path = self._get_and_validate_svn_path("Update Branch Display") current_branch_name = "" # Default value # Only query git if repo is ready if svn_path and os.path.exists(os.path.join(svn_path, ".git")): try: # Use GitCommands to get branch name branch_name = self.git_commands.get_current_branch(svn_path) # Update display text based on result if branch_name == "(DETACHED HEAD)": current_branch_name = branch_name # Show detached state clearly elif branch_name == "": current_branch_name = "" # Show error state elif branch_name: current_branch_name = branch_name # Show actual branch name else: # Fallback if method returns None unexpectedly current_branch_name = "" except Exception as e: # Handle exceptions during git command execution self.logger.error(f"Failed to get current branch: {e}") current_branch_name = "" # Update the GUI label via MainFrame method if hasattr(self, 'main_frame'): self.main_frame.set_current_branch_display(current_branch_name) def ui_create_branch(self): """Callback for 'Create Branch' button.""" self.logger.info("--- Action Triggered: Create Branch ---") svn_path = self._get_and_validate_svn_path("Create Branch") if not svn_path: return # Use custom dialog to get new branch name dialog = CreateBranchDialog(self.master) new_branch_name = dialog.result # Returns name or None if not new_branch_name: self.logger.info("Branch creation cancelled."); return self.logger.info(f"Attempting create branch: '{new_branch_name}'") # Save settings? Optional. # Delegate execution to ActionHandler try: # Assuming ActionHandler has execute_create_branch # TODO: Add start_point logic later if needed success = self.action_handler.execute_create_branch( svn_path, new_branch_name ) if success: self.main_frame.show_info("Success", f"Branch '{new_branch_name}' created.") self.refresh_branch_list() # Update list # Optionally ask to switch except (GitCommandError, ValueError) as e: self.main_frame.show_error("Error", f"Could not create branch:\n{e}") except Exception as e: self.logger.exception(f"Unexpected error creating branch: {e}") self.main_frame.show_error("Error", f"Unexpected error:\n{e}") def ui_switch_branch(self): """Callback for 'Switch to Selected Branch' button.""" self.logger.info("--- Action Triggered: Switch Branch ---") svn_path = self._get_and_validate_svn_path("Switch Branch") if not svn_path: return selected_branch = self.main_frame.get_selected_branch() # Gets name if not selected_branch: self.main_frame.show_error("Error", "Select a branch."); return # Prevent switching to the same branch current_branch = self.main_frame.current_branch_var.get() if selected_branch == current_branch: self.main_frame.show_info("Info", f"Already on branch '{selected_branch}'.") return self.logger.info(f"Attempting switch to branch: {selected_branch}") # Save settings? Optional. # Delegate execution (ActionHandler checks for changes) try: # Assuming ActionHandler has execute_switch_branch success = self.action_handler.execute_switch_branch( svn_path, selected_branch ) if success: self.main_frame.show_info("Success", f"Switched to branch '{selected_branch}'.") # Update UI after successful switch self.update_current_branch_display() # Update label immediately self.refresh_branch_list() # Update highlight in list # else: Handler raises error except ValueError as e: # Catch specific errors like uncommitted changes self.main_frame.show_error("Switch Blocked", str(e)) except GitCommandError as e: # Catch errors like branch not found self.main_frame.show_error("Error", f"Could not switch branch:\n{e}") except Exception as e: self.logger.exception(f"Unexpected error switching branch: {e}") self.main_frame.show_error("Error", f"Unexpected error:\n{e}") def ui_delete_branch(self): """Callback for 'Delete Selected Branch' button.""" self.logger.info("--- Action Triggered: Delete Branch ---") svn_path = self._get_and_validate_svn_path("Delete Branch") if not svn_path: return selected_branch = self.main_frame.get_selected_branch() # Gets name if not selected_branch: self.main_frame.show_error("Error", "Select a branch."); return # Prevent deleting current or main branches current_branch = self.main_frame.current_branch_var.get() if selected_branch == current_branch: self.main_frame.show_error("Error", "Cannot delete current branch."); return if selected_branch in ["main", "master"]: self.main_frame.show_error("Error", f"Cannot delete '{selected_branch}'."); return # Confirmation msg = f"Delete local branch '{selected_branch}'?\nCannot be undone easily!" if not self.main_frame.ask_yes_no("Confirm Delete Branch", msg): self.logger.info("Branch deletion cancelled."); return self.logger.info(f"Attempting delete branch: {selected_branch}") # Save settings? Unlikely needed. # Delegate execution try: # Attempt safe delete first success = self.action_handler.execute_delete_branch( svn_path, selected_branch, force=False ) if success: self.main_frame.show_info("Success", f"Branch '{selected_branch}' deleted.") self.refresh_branch_list() # Update list # else: Handler should raise error except GitCommandError as e: # Handle specific errors, like 'not fully merged' if "not fully merged" in str(e).lower(): force_msg = (f"Branch '{selected_branch}' not fully merged.\n" f"Force delete anyway (irreversible)?") if self.main_frame.ask_yes_no("Force Delete?", force_msg): try: # Attempt force delete via ActionHandler force_success = self.action_handler.execute_delete_branch( svn_path, selected_branch, force=True ) if force_success: self.main_frame.show_info("Success", f"Branch '{selected_branch}' force deleted.") self.refresh_branch_list() # else: Handler should raise error if force delete fails except Exception as force_e: self.logger.error(f"Force delete failed: {force_e}", exc_info=True) self.main_frame.show_error("Error", f"Force delete failed:\n{force_e}") else: self.logger.info("Force delete cancelled.") else: # Other Git errors self.main_frame.show_error("Error", f"Could not delete branch:\n{e}") except Exception as e: self.logger.exception(f"Unexpected error deleting branch: {e}") self.main_frame.show_error("Error", f"Unexpected error:\n{e}") # --- GUI State Utilities --- def _clear_and_disable_fields(self): """Clears relevant GUI fields and disables most buttons.""" if hasattr(self, 'main_frame'): mf = self.main_frame # Alias # Clear Repo frame fields mf.svn_path_entry.delete(0, tk.END) mf.usb_path_entry.delete(0, tk.END) mf.bundle_name_entry.delete(0, tk.END) mf.bundle_updated_name_entry.delete(0, tk.END) # Clear Commit/Tag/Branch frame fields mf.commit_message_var.set("") mf.autocommit_var.set(False) mf.update_tag_list([]) # Clear tag listbox mf.update_branch_list([]) # Clear branch listbox mf.set_current_branch_display("") # Reset indicator and dependent buttons/widgets # This handles disabling Prepare, EditGitignore, Commit/Tag/Branch widgets self.update_svn_status_indicator("") # Disable general action buttons explicitly self._disable_general_buttons() self.logger.debug("GUI fields cleared/reset. Buttons disabled.") def _disable_general_buttons(self): """Disables buttons generally requiring only a loaded profile.""" if hasattr(self, 'main_frame'): # List of general action button attribute names in main_frame button_names = [ 'create_bundle_button', 'fetch_bundle_button', 'manual_backup_button', 'save_settings_button' ] # Iterate and disable if the button exists for name in button_names: button = getattr(self.main_frame, name, None) if button: button.config(state=tk.DISABLED) def _enable_function_buttons(self): """ Enables general action buttons. State-dependent buttons rely on update_svn_status_indicator for their state. """ if hasattr(self, 'main_frame'): general_state = tk.NORMAL # List of general action button attribute names button_names = [ 'create_bundle_button', 'fetch_bundle_button', 'manual_backup_button', 'save_settings_button' ] # Iterate and enable if the button exists for name in button_names: button = getattr(self.main_frame, name, None) if button: button.config(state=general_state) # Ensure state-dependent buttons reflect the current status # This call updates Prepare, EditGitignore, Commit/Tag/Branch widget states current_svn_path = "" if hasattr(self.main_frame, 'svn_path_entry'): current_svn_path = self.main_frame.svn_path_entry.get() self.update_svn_status_indicator(current_svn_path) self.logger.debug("General buttons enabled. State buttons updated.") def show_fatal_error(self, message): """Shows a fatal error message.""" try: # Determine parent window safely parent = None if hasattr(self, 'master') and self.master and self.master.winfo_exists(): parent = self.master messagebox.showerror("Fatal Error", message, parent=parent) except tk.TclError: # Fallback if GUI is not ready or fails print(f"FATAL ERROR: {message}") except Exception as e: # Log error showing the message box itself print(f"FATAL ERROR (and GUI error: {e}): {message}") def resource_path(relative_path): """ Ottiene il percorso assoluto della risorsa, funziona per dev e per PyInstaller """ try: # PyInstaller crea una cartella temporanea e salva il percorso in _MEIPASS base_path = sys._MEIPASS except Exception: # _MEIPASS non esiste, siamo in modalità sviluppo normale base_path = os.path.abspath(".") # Usa la directory corrente return os.path.join(base_path, relative_path) # --- Application Entry Point --- def main(): """Main function: Creates Tkinter root and runs the application.""" root = tk.Tk() # Adjust min size for the new layout with tabs root.minsize(750, 650) # Adjusted min height after tabbing # --- Imposta l'icona della finestra --- try: # Assumendo che 'app_icon.ico' sia nella stessa dir dello script # o aggiunto correttamente a PyInstaller icon_path = resource_path("GitUtility.ico") # Usa wm_iconbitmap per Windows if os.path.exists(icon_path): # wm_iconbitmap si aspetta un file .ico su Windows # Per Linux/Mac, si userebbe iconphoto con un PhotoImage (PNG) if os.name == 'nt': # Solo per Windows root.wm_iconbitmap(icon_path) else: # Su Linux/Mac potresti usare iconphoto con un file PNG # icon_img = tk.PhotoImage(file=resource_path("app_icon.png")) # root.iconphoto(True, icon_img) # 'True' per default icon # Nota: Dovresti aggiungere app_icon.png con --add-data pass # Per ora non facciamo nulla su altri OS else: # Log se l'icona non viene trovata nel percorso atteso logging.warning(f"Window icon file not found at: {icon_path}") except tk.TclError as e: # Logga se c'è un errore nel caricare/impostare l'icona logging.warning(f"Could not set window icon: {e}") except Exception as e: # Logga altri errori imprevisti logging.warning(f"Unexpected error setting window icon: {e}", exc_info=True) app = None # Initialize app variable try: app = GitSvnSyncApp(root) # Start main loop only if initialization likely succeeded if hasattr(app, 'main_frame') and app.main_frame: root.mainloop() else: # Initialization failed before GUI setup could complete print("Application initialization failed, exiting.") # Ensure window closes if init failed but window was created if root and root.winfo_exists(): root.destroy() except Exception as e: # Catch-all for unexpected errors during startup or main loop logging.exception("Fatal error during application startup or main loop.") # Try showing message box, fallback to print try: parent_window = root if root and root.winfo_exists() else None messagebox.showerror("Fatal Error", f"Application failed unexpectedly:\n{e}", parent=parent_window) except Exception as msg_e: print(f"FATAL ERROR (GUI error: {msg_e}): App failed:\n{e}") finally: # Log application exit regardless of success or failure logging.info("Application exiting.") if __name__ == "__main__": # Setup basic logging immediately at startup log_format = "%(asctime)s - %(levelname)s - [%(module)s:%(funcName)s:%(lineno)d] - %(message)s" # Consider level=logging.DEBUG for more detail during development logging.basicConfig(level=logging.INFO, format=log_format) main()