# GitTool.py import os import shutil import datetime import tkinter as tk from tkinter import messagebox import logging import zipfile # 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, including the new dialog from gui import MainFrame, GitignoreEditorWindow, CreateTagDialog class GitSvnSyncApp: """ Main application class for the Git SVN Sync Tool. Coordinates the GUI, configuration, and Git command execution. """ 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") # Handle window close event gracefully master.protocol("WM_DELETE_WINDOW", self.on_closing) # --- Early Logger Setup --- # Basic config first in case setup_logger has issues logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) # Get the application-specific logger instance self.logger = logging.getLogger("GitSvnSyncApp") # --- Configuration Manager --- # Initialize ConfigManager try: self.config_manager = ConfigManager(self.logger) except Exception as e: self.logger.critical( f"Failed to initialize ConfigManager: {e}", exc_info=True ) self.show_fatal_error( f"Failed to load or create configuration file.\n{e}\n" f"Application cannot continue." ) # Ensure window is destroyed if initialization fails critically master.destroy() # Stop initialization if config fails return # --- GUI Main Frame --- # Create the main GUI frame, passing required callbacks try: self.main_frame = MainFrame( master, load_profile_settings_cb=self.load_profile_settings, browse_folder_cb=self.browse_folder, update_svn_status_cb=self.update_svn_status_indicator, prepare_svn_for_git_cb=self.prepare_svn_for_git, create_git_bundle_cb=self.create_git_bundle, fetch_from_git_bundle_cb=self.fetch_from_git_bundle, config_manager_instance=self.config_manager, profile_sections_list=self.config_manager.get_profile_sections(), add_profile_cb=self.add_profile, remove_profile_cb=self.remove_profile, manual_backup_cb=self.manual_backup, open_gitignore_editor_cb=self.open_gitignore_editor, save_profile_cb=self.save_profile_settings, # Save button callback # Pass Tag Management Callbacks refresh_tags_cb=self.refresh_tag_list, create_tag_cb=self.create_tag, # Triggers new flow checkout_tag_cb=self.checkout_tag ) except Exception as e: self.logger.critical( f"Failed to initialize MainFrame GUI: {e}", exc_info=True ) self.show_fatal_error( f"Failed to create the main application window.\n{e}\n" f"Application cannot continue." ) master.destroy() return # --- Enhanced Logger Setup --- # Configure logger using the GUI widget self.logger = setup_logger(self.main_frame.log_text) # Update ConfigManager's logger instance self.config_manager.logger = self.logger # --- Git Commands Handler --- # Initialize GitCommands (decoupled from GUI) self.git_commands = GitCommands(self.logger) # --- Initial Application State --- self.logger.info("Application initializing...") # Load settings for the initially selected profile # This is triggered by the trace on profile_var in MainFrame's __init__ initial_profile = self.main_frame.profile_var.get() if initial_profile: self.logger.debug(f"Initial profile: '{initial_profile}'. Loading...") # load_profile_settings is called via trace and handles tag refresh else: self.logger.warning("No profile selected on startup.") # Clear fields and disable buttons if no profile is selected self._clear_and_disable_fields() self.logger.info("Application started successfully.") def on_closing(self): """Handles the event when the user tries to close the window.""" self.logger.info("Close button clicked. Preparing to exit.") # TODO: Add checks for unsaved changes or running operations if needed # For example, check if GitignoreEditorWindow is open and modified. self.logger.info("Application closing.") self.master.destroy() # --- Profile Management Callbacks/Methods --- def load_profile_settings(self, profile_name): """Loads settings for the specified profile into the GUI fields.""" self.logger.info(f"Loading settings for profile: '{profile_name}'") # Handle case where no profile is selected (e.g., after removing last one) if not profile_name: self.logger.warning("Attempted load settings with no profile selected.") self._clear_and_disable_fields() return # Check if the profile actually exists in the configuration if profile_name not in self.config_manager.get_profile_sections(): self.logger.error(f"Profile '{profile_name}' not found in config.") self.main_frame.show_error( "Profile Error", f"Profile '{profile_name}' not found." ) self._clear_and_disable_fields() return # Load values using ConfigManager with appropriate fallbacks cm = self.config_manager # Alias for brevity svn_path = cm.get_profile_option(profile_name, "svn_working_copy_path", "") usb_path = cm.get_profile_option(profile_name, "usb_drive_path", "") bundle_name = cm.get_profile_option(profile_name, "bundle_name", "") bundle_upd_name = cm.get_profile_option(profile_name, "bundle_name_updated", "") autocommit_str = cm.get_profile_option(profile_name, "autocommit", "False") commit_msg = cm.get_profile_option(profile_name, "commit_message", "") autobackup_str = cm.get_profile_option(profile_name, "autobackup", "False") backup_dir = cm.get_profile_option(profile_name, "backup_dir", DEFAULT_BACKUP_DIR) excludes = cm.get_profile_option(profile_name, "backup_exclude_extensions", ".log,.tmp") # Update GUI Elements safely (check if main_frame exists) if hasattr(self, 'main_frame'): mf = self.main_frame # Alias # Update Repository Frame widgets mf.svn_path_entry.delete(0, tk.END) mf.svn_path_entry.insert(0, svn_path) mf.usb_path_entry.delete(0, tk.END) mf.usb_path_entry.insert(0, usb_path) mf.bundle_name_entry.delete(0, tk.END) mf.bundle_name_entry.insert(0, bundle_name) mf.bundle_updated_name_entry.delete(0, tk.END) mf.bundle_updated_name_entry.insert(0, bundle_upd_name) # Update Commit/Tag Frame widgets mf.commit_message_var.set(commit_msg) mf.autocommit_var.set(autocommit_str.lower() == "true") # Update Backup Frame widgets mf.autobackup_var.set(autobackup_str.lower() == "true") mf.backup_dir_var.set(backup_dir) mf.backup_exclude_extensions_var.set(excludes) # Update state of backup dir entry based on checkbox mf.toggle_backup_dir() # Update status indicator and dependent buttons self.update_svn_status_indicator(svn_path) # Enable general function buttons self._enable_function_buttons() # Refresh tag list if the repo is valid and prepared repo_is_ready = ( svn_path and os.path.isdir(svn_path) and os.path.exists(os.path.join(svn_path, ".git")) ) if repo_is_ready: self.refresh_tag_list() else: # Clear tag list if path invalid or repo not prepared mf.update_tag_list([]) self.logger.info(f"Settings loaded successfully for '{profile_name}'.") else: self.logger.error("Cannot load settings: Main frame unavailable.") def save_profile_settings(self): """Saves the current GUI field values to the selected profile.""" profile = self.main_frame.profile_var.get() if not profile: self.logger.warning("Cannot save settings: No profile selected.") # Show error only if explicitly triggered by user via Save button? # self.main_frame.show_error("Save Error", "No profile selected.") return False # Indicate failure self.logger.info(f"Saving settings for profile: '{profile}'") try: cm = self.config_manager # Alias mf = self.main_frame # Alias # Save Repository settings cm.set_profile_option(profile, "svn_working_copy_path", mf.svn_path_entry.get()) cm.set_profile_option(profile, "usb_drive_path", mf.usb_path_entry.get()) cm.set_profile_option(profile, "bundle_name", mf.bundle_name_entry.get()) cm.set_profile_option(profile, "bundle_name_updated", mf.bundle_updated_name_entry.get()) # Save Commit/Tag settings cm.set_profile_option(profile, "autocommit", str(mf.autocommit_var.get())) cm.set_profile_option(profile, "commit_message", mf.commit_message_var.get()) # Save Backup settings cm.set_profile_option(profile, "autobackup", str(mf.autobackup_var.get())) cm.set_profile_option(profile, "backup_dir", mf.backup_dir_var.get()) cm.set_profile_option(profile, "backup_exclude_extensions", mf.backup_exclude_extensions_var.get()) # Persist changes to the configuration file cm.save_config() self.logger.info(f"Profile settings for '{profile}' saved successfully.") # Optionally provide visual feedback on save success # self.main_frame.show_info("Saved", f"Settings saved for '{profile}'.") return True # Indicate success except Exception as e: self.logger.error(f"Error saving settings for '{profile}': {e}", exc_info=True) self.main_frame.show_error("Save Error", f"Failed to save settings:\n{e}") return False # Indicate failure def add_profile(self): """Handles adding a new profile.""" self.logger.debug("'Add Profile' button clicked.") # Get new profile name from user via dialog new_profile_name = self.main_frame.ask_new_profile_name() if not new_profile_name: # User cancelled the dialog self.logger.info("Profile addition cancelled by user.") return new_profile_name = new_profile_name.strip() if not new_profile_name: # Empty name provided self.logger.warning("Attempted to add profile with empty name.") self.main_frame.show_error("Error", "Profile name cannot be empty.") return # Check if profile name already exists if new_profile_name in self.config_manager.get_profile_sections(): self.logger.warning(f"Profile name already exists: '{new_profile_name}'") self.main_frame.show_error( "Error", f"Profile name '{new_profile_name}' already exists." ) return # Proceed with adding the profile self.logger.info(f"Adding new profile: '{new_profile_name}'") try: # Get default values for all keys defaults = self.config_manager._get_expected_keys_with_defaults() # Customize defaults specific to a new profile defaults["bundle_name"] = f"{new_profile_name}_repo.bundle" defaults["bundle_name_updated"] = f"{new_profile_name}_update.bundle" defaults["svn_working_copy_path"] = "" # Start with empty paths defaults["usb_drive_path"] = "" # Start with empty paths # Set all default options for the new profile for key, value in defaults.items(): self.config_manager.set_profile_option(new_profile_name, key, value) # Save the updated configuration self.config_manager.save_config() # Update the GUI dropdown list updated_sections = self.config_manager.get_profile_sections() self.main_frame.update_profile_dropdown(updated_sections) # Select the newly added profile in the dropdown # This will trigger load_profile_settings via the trace self.main_frame.profile_var.set(new_profile_name) self.logger.info(f"Profile '{new_profile_name}' added successfully.") except Exception as e: self.logger.error(f"Error adding profile '{new_profile_name}': {e}", exc_info=True) self.main_frame.show_error("Error", f"Failed to add profile:\n{e}") def remove_profile(self): """Handles removing the currently selected profile.""" self.logger.debug("'Remove Profile' button clicked.") profile_to_remove = self.main_frame.profile_var.get() if not profile_to_remove: self.logger.warning("Attempted remove when no profile selected.") self.main_frame.show_error("Error", "No profile selected to remove.") return # Prevent removing the default profile if profile_to_remove == DEFAULT_PROFILE: self.logger.warning("Attempted to remove the default profile.") self.main_frame.show_error( "Error", f"Cannot remove the '{DEFAULT_PROFILE}' profile." ) return # Confirmation dialog confirm_msg = (f"Are you sure you want to permanently remove profile " f"'{profile_to_remove}'?") if self.main_frame.ask_yes_no("Remove Profile", confirm_msg): self.logger.info(f"Attempting remove profile: '{profile_to_remove}'") try: # Remove section via ConfigManager success = self.config_manager.remove_profile_section( profile_to_remove ) if success: self.config_manager.save_config() # Save changes self.logger.info("Profile removed successfully.") # Update dropdown - new profile selection triggers load updated_sections = self.config_manager.get_profile_sections() self.main_frame.update_profile_dropdown(updated_sections) else: # ConfigManager should have logged reason self.main_frame.show_error( "Error", f"Failed to remove profile '{profile_to_remove}'. See logs." ) except Exception as e: self.logger.error( f"Unexpected error removing profile '{profile_to_remove}': {e}", exc_info=True ) self.main_frame.show_error( "Error", f"An unexpected error occurred removing profile:\n{e}" ) else: # User clicked 'No' in the confirmation dialog self.logger.info("Profile removal cancelled by user.") # --- GUI Interaction Callbacks --- def browse_folder(self, entry_widget): """Opens folder dialog and updates the specified Tkinter Entry.""" self.logger.debug("Browse folder requested.") # Determine initial directory for dialog current_path = entry_widget.get() # Suggest current path if valid, else user's home directory initial_dir = current_path if os.path.isdir(current_path) else \ os.path.expanduser("~") # Show folder selection dialog directory = filedialog.askdirectory( initialdir=initial_dir, title="Select Directory", parent=self.master # Make dialog modal to main window ) if directory: # User selected a directory self.logger.debug(f"Directory selected: {directory}") # Update the entry widget's content entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) # If the SVN path entry was changed, trigger status update if entry_widget == self.main_frame.svn_path_entry: self.update_svn_status_indicator(directory) else: # User cancelled the dialog self.logger.debug("Folder browse dialog cancelled.") def update_svn_status_indicator(self, svn_path): """ Checks repo status, updates indicator, and enables/disables Prepare, Edit Gitignore, and Commit/Tag widgets. """ # Determine directory validity and Git preparation status is_valid_dir = bool(svn_path and os.path.isdir(svn_path)) is_prepared = False if is_valid_dir: git_dir_path = os.path.join(svn_path, ".git") is_prepared = os.path.exists(git_dir_path) self.logger.debug( f"Updating status for '{svn_path}'. Valid: {is_valid_dir}, " f"Prepared: {is_prepared}" ) # Update GUI elements safely if hasattr(self, 'main_frame'): mf = self.main_frame # Alias # Update indicator and Prepare button via MainFrame method mf.update_svn_indicator(is_prepared) # Determine state for other dependent widgets # Edit Gitignore button needs a valid directory path gitignore_state = tk.NORMAL if is_valid_dir else tk.DISABLED # Commit/Tag widgets need a prepared Git repository commit_tag_state = tk.NORMAL if is_prepared else tk.DISABLED # Update Edit Gitignore button state if hasattr(mf, 'edit_gitignore_button'): mf.edit_gitignore_button.config(state=gitignore_state) # Update Commit/Tag section widgets state if hasattr(mf, 'commit_message_entry'): mf.commit_message_entry.config(state=commit_tag_state) if hasattr(mf, 'autocommit_checkbox'): mf.autocommit_checkbox.config(state=commit_tag_state) if hasattr(mf, 'refresh_tags_button'): mf.refresh_tags_button.config(state=commit_tag_state) if hasattr(mf, 'create_tag_button'): mf.create_tag_button.config(state=commit_tag_state) if hasattr(mf, 'checkout_tag_button'): mf.checkout_tag_button.config(state=commit_tag_state) def open_gitignore_editor(self): """Opens the editor window for the .gitignore file.""" self.logger.info("--- Action: Edit .gitignore ---") # Validate the SVN Path first svn_path = self._get_and_validate_svn_path("Edit .gitignore") if not svn_path: return # Stop if path is invalid # Construct the path to .gitignore gitignore_path = os.path.join(svn_path, ".gitignore") self.logger.debug(f"Target .gitignore path: {gitignore_path}") # Open the Editor Window try: # Create and run the modal editor window editor = GitignoreEditorWindow(self.master, gitignore_path, self.logger) self.logger.debug("Gitignore editor window opened.") # Execution blocks here until the editor window is closed except Exception as e: # Handle errors during editor creation/opening self.logger.exception(f"Error opening .gitignore editor: {e}") self.main_frame.show_error( "Editor Error", f"Could not open the .gitignore editor:\n{e}" ) # --- Core Functionality Methods --- def _get_and_validate_svn_path(self, operation_name="Operation"): """Retrieves and validates the SVN path from the GUI.""" # Check if main_frame and widget exist if not hasattr(self, 'main_frame') or \ not self.main_frame.winfo_exists() or \ not hasattr(self.main_frame, 'svn_path_entry'): self.logger.error(f"{operation_name}: GUI component unavailable.") return None svn_path_str = self.main_frame.svn_path_entry.get().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 (not a directory):\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.""" # Check if main_frame and widget exist if not hasattr(self, 'main_frame') or \ not self.main_frame.winfo_exists() or \ not hasattr(self.main_frame, 'usb_path_entry'): self.logger.error(f"{operation_name}: GUI component unavailable.") return None usb_path_str = self.main_frame.usb_path_entry.get().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 Directory cannot be 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 directory: {abs_path}" ) self.main_frame.show_error( "Input Error", f"Invalid Bundle Target path (not a directory):\n{abs_path}" ) return None self.logger.debug(f"{operation_name}: Validated Bundle Target path: {abs_path}") return abs_path def prepare_svn_for_git(self): """Handles the 'Prepare SVN for Git' action.""" self.logger.info("--- Action: Prepare SVN Repo ---") svn_path = self._get_and_validate_svn_path("Prepare SVN") if not svn_path: return # Validation failed # Save settings *before* potentially modifying .gitignore if not self.save_profile_settings(): self.logger.warning("Prepare SVN: Could not save profile settings.") # Consider asking user if they want to continue # Check if already prepared to avoid redundant operations git_dir_path = os.path.join(svn_path, ".git") if os.path.exists(git_dir_path): self.logger.info(f"Repository already prepared: {svn_path}") self.main_frame.show_info("Already Prepared", "Repository already prepared.") self.update_svn_status_indicator(svn_path) # Ensure UI state correct return # Execute Preparation command self.logger.info(f"Executing preparation for: {svn_path}") try: self.git_commands.prepare_svn_for_git(svn_path) self.logger.info("Repository prepared successfully.") self.main_frame.show_info("Success", "Repository prepared.") # Update indicator and dependent buttons self.update_svn_status_indicator(svn_path) except (GitCommandError, ValueError) as e: # Handle known errors self.logger.error(f"Error preparing repository: {e}") self.main_frame.show_error("Preparation Error", f"Failed:\n{e}") self.update_svn_status_indicator(svn_path) # Update state except Exception as e: # Handle unexpected errors self.logger.exception(f"Unexpected error during preparation: {e}") self.main_frame.show_error("Error", f"Unexpected error:\n{e}") self.update_svn_status_indicator(svn_path) def create_git_bundle(self): """Handles the 'Create Bundle' action.""" self.logger.info("--- Action: Create Git Bundle ---") profile = self.main_frame.profile_var.get() if not profile: self.logger.error("Create Bundle: No profile selected.") self.main_frame.show_error("Error", "No profile selected.") return # Validate paths and bundle name 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.logger.error("Create Bundle: Bundle name is empty.") self.main_frame.show_error("Input Error", "Bundle name is empty.") return # Ensure .bundle extension if not bundle_name.lower().endswith(".bundle"): self.logger.warning(f"Adding .bundle extension to '{bundle_name}'.") bundle_name += ".bundle" # Update the GUI field to reflect the change self.main_frame.bundle_name_entry.delete(0, tk.END) self.main_frame.bundle_name_entry.insert(0, bundle_name) bundle_full_path = os.path.join(usb_path, bundle_name) self.logger.debug(f"Target bundle file: {bundle_full_path}") # Save current profile settings before proceeding if not self.save_profile_settings(): self.logger.warning("Create Bundle: Could not save settings.") # Ask user? # --- Backup Step --- if self.main_frame.autobackup_var.get(): self.logger.info("Autobackup enabled. Starting backup...") backup_success = self.create_backup(svn_path, profile) if not backup_success: self.logger.error("Aborted bundle creation: Backup failed.") return # Stop if backup fails # --- Autocommit Step (if checkbox enabled) --- if self.main_frame.autocommit_var.get(): self.logger.info("Autocommit before bundle is enabled.") try: # Check for changes first has_changes = self.git_commands.git_status_has_changes(svn_path) if has_changes: self.logger.info("Changes detected, performing autocommit...") # Use message from Commit/Tag frame or default custom_message = self.main_frame.commit_message_var.get().strip() commit_msg = custom_message if custom_message else \ f"Autocommit profile '{profile}' before bundle" self.logger.debug(f"Using commit message: '{commit_msg}'") # Perform the commit commit_made = self.git_commands.git_commit(svn_path, commit_msg) if commit_made: self.logger.info("Autocommit successful.") # else: git_commit logs 'nothing to commit' else: self.logger.info("No changes detected for autocommit.") except (GitCommandError, ValueError) as e: self.logger.error(f"Autocommit error: {e}") self.main_frame.show_error("Autocommit Error", f"Failed:\n{e}") return # Stop process if commit fails except Exception as e: self.logger.exception(f"Unexpected autocommit error: {e}") self.main_frame.show_error("Error", f"Unexpected commit error:\n{e}") return # --- Create Bundle Step --- self.logger.info(f"Creating bundle file: {bundle_full_path}") try: # Execute the bundle creation command self.git_commands.create_git_bundle(svn_path, bundle_full_path) # Verify bundle creation success (exists and not empty) bundle_exists = os.path.exists(bundle_full_path) bundle_not_empty = bundle_exists and os.path.getsize(bundle_full_path) > 0 if bundle_exists and bundle_not_empty: self.logger.info("Git bundle created successfully.") self.main_frame.show_info("Success", f"Bundle created:\n{bundle_full_path}") else: # Bundle likely empty or command had non-fatal warning self.logger.warning("Bundle file not created or is empty.") self.main_frame.show_warning( "Bundle Not Created", "Bundle empty or not created.\n(Likely no new commits)." ) # Clean up empty file if it exists if bundle_exists and not bundle_not_empty: try: os.remove(bundle_full_path) self.logger.info("Removed empty bundle file.") except OSError: self.logger.warning("Could not remove empty bundle file.") except (GitCommandError, ValueError) as e: self.logger.error(f"Error creating Git bundle: {e}") self.main_frame.show_error("Error", f"Failed create bundle:\n{e}") except Exception as e: self.logger.exception(f"Unexpected error during bundle creation: {e}") self.main_frame.show_error("Error", f"Unexpected bundle error:\n{e}") def fetch_from_git_bundle(self): """Handles the 'Fetch from Bundle' action.""" self.logger.info("--- Action: Fetch from Git Bundle ---") profile = self.main_frame.profile_var.get() if not profile: self.logger.error("Fetch: No profile selected.") self.main_frame.show_error("Error", "No profile selected.") return # Validate paths and bundle name 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.logger.error("Fetch: Fetch bundle name empty.") self.main_frame.show_error("Input Error", "Fetch bundle name empty.") return bundle_full_path = os.path.join(usb_path, bundle_name) self.logger.debug(f"Source bundle file: {bundle_full_path}") # Check if Bundle File Exists if not os.path.isfile(bundle_full_path): self.logger.error(f"Fetch: Bundle file not found: {bundle_full_path}") self.main_frame.show_error( "File Not Found", f"Bundle file not found:\n{bundle_full_path}" ) return # Save settings before potentially changing repo state if not self.save_profile_settings(): self.logger.warning("Fetch: Could not save profile settings.") # Ask user? # --- Backup Step --- if self.main_frame.autobackup_var.get(): self.logger.info("Autobackup enabled. Starting backup...") backup_success = self.create_backup(svn_path, profile) if not backup_success: self.logger.error("Aborted fetch: Backup failed.") return # --- Fetch and Merge Step --- self.logger.info(f"Fetching into '{svn_path}' from: {bundle_full_path}") try: # Execute the fetch/merge command self.git_commands.fetch_from_git_bundle(svn_path, bundle_full_path) self.logger.info("Fetch/merge process completed.") # Inform user, acknowledging potential conflicts self.main_frame.show_info( "Fetch Complete", f"Fetch complete.\nCheck logs for merge status/conflicts." ) except GitCommandError as e: # Handle specific errors like merge conflicts self.logger.error(f"Error fetching/merging: {e}") if "merge conflict" in str(e).lower(): self.main_frame.show_error( "Merge Conflict", f"Merge conflict occurred.\nResolve manually in:\n{svn_path}\n" f"Then run 'git add .' and 'git commit'." ) else: # Show other Git command errors self.main_frame.show_error("Fetch/Merge Error", f"Failed:\n{e}") except ValueError as e: # Handle validation errors passed up self.logger.error(f"Validation error during fetch: {e}") self.main_frame.show_error("Input Error", f"Invalid input:\n{e}") except Exception as e: # Handle unexpected errors self.logger.exception(f"Unexpected error during fetch/merge: {e}") self.main_frame.show_error("Error", f"Unexpected fetch error:\n{e}") # --- Backup Logic --- def _parse_exclusions(self, profile_name): """Parses exclusion string from config into sets.""" 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: # Split by comma, clean up each part raw_extensions = exclude_str.split(',') for ext in raw_extensions: clean_ext = ext.strip().lower() if clean_ext: # 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 def create_backup(self, source_repo_path, profile_name): """Creates a timestamped ZIP backup, respecting profile exclusions.""" self.logger.info( f"Creating ZIP backup for '{profile_name}' from '{source_repo_path}'" ) # Get and Validate Backup Destination Directory backup_base_dir = self.main_frame.backup_dir_var.get().strip() if not backup_base_dir: self.logger.error("Backup Fail: Backup directory empty.") self.main_frame.show_error("Backup Error", "Backup directory empty.") return False # Ensure directory exists, create if necessary if not os.path.isdir(backup_base_dir): self.logger.info(f"Creating backup dir: {backup_base_dir}") try: os.makedirs(backup_base_dir, exist_ok=True) except OSError as e: self.logger.error(f"Cannot create backup dir: {e}", exc_info=True) self.main_frame.show_error("Backup Error", f"Cannot create dir:\n{e}") return False # Parse Exclusions for the profile try: excluded_extensions, excluded_dirs_base = self._parse_exclusions(profile_name) except Exception as e: self.logger.error(f"Failed parse exclusions: {e}", exc_info=True) self.main_frame.show_error("Backup Error", "Cannot parse exclusions.") return False # Construct Backup Filename now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") # Sanitize profile name for use in filename safe_profile = "".join(c for c in profile_name if c.isalnum() or c in '_-').rstrip() or "profile" backup_filename = f"{now_str}_backup_{safe_profile}.zip" backup_full_path = os.path.join(backup_base_dir, backup_filename) self.logger.info(f"Target backup ZIP file: {backup_full_path}") # Create ZIP Archive files_added = 0 files_excluded = 0 dirs_excluded = 0 zip_f = None # Initialize zip file object try: # Open ZIP file with appropriate settings zip_f = zipfile.ZipFile(backup_full_path, 'w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) # Support large archives # Walk through the source directory for root, dirs, files in os.walk(source_repo_path, topdown=True): # --- Directory Exclusion --- original_dirs = list(dirs) # Copy before modifying # Exclude based on base name (case-insensitive) dirs[:] = [d for d in dirs if d.lower() not in excluded_dirs_base] # Log excluded directories for this level excluded_dirs_now = set(original_dirs) - set(dirs) if excluded_dirs_now: dirs_excluded += len(excluded_dirs_now) for ex_dir in excluded_dirs_now: path_excluded = os.path.join(root, ex_dir) self.logger.debug(f"Excluding directory: {path_excluded}") # --- File Exclusion and Addition --- for filename in files: # Get file extension (lowercase for comparison) _, file_ext = os.path.splitext(filename) file_ext_lower = file_ext.lower() # Check exclusion rules (filename matches dir OR extension matches) # Case-insensitive check for filename matching excluded dirs if filename.lower() in excluded_dirs_base or \ file_ext_lower in excluded_extensions: path_excluded = os.path.join(root, filename) self.logger.debug(f"Excluding file: {path_excluded}") files_excluded += 1 continue # Skip this file # If not excluded, add file to ZIP file_full_path = os.path.join(root, filename) # Store with relative path inside ZIP archive archive_name = os.path.relpath(file_full_path, source_repo_path) try: zip_f.write(file_full_path, arcname=archive_name) files_added += 1 # Log progress occasionally for large backups if files_added % 500 == 0: self.logger.debug(f"Added {files_added} files...") except Exception as write_e: # Log error writing specific file but continue backup process self.logger.error( f"Error writing file '{file_full_path}' to ZIP: {write_e}", exc_info=True ) # Log final summary after successful walk and write attempts self.logger.info(f"Backup ZIP creation finished: {backup_full_path}") self.logger.info( f"Summary - Added: {files_added}, Excl Files: {files_excluded}, " f"Excl Dirs: {dirs_excluded}" ) return True # Indicate backup process completed except OSError as e: # Handle OS-level errors (permissions, disk space, etc.) self.logger.error(f"OS error during backup ZIP creation: {e}", exc_info=True) self.main_frame.show_error("Backup Error", f"OS Error creating ZIP:\n{e}") return False except zipfile.BadZipFile as e: # Handle errors related to the ZIP file format itself self.logger.error(f"ZIP format error during backup: {e}", exc_info=True) self.main_frame.show_error("Backup Error", f"ZIP format error:\n{e}") return False except Exception as e: # Catch any other unexpected error during the process self.logger.exception(f"Unexpected error during ZIP backup: {e}") self.main_frame.show_error("Backup Error", f"Unexpected ZIP error:\n{e}") return False finally: # Ensure the ZIP file is always closed, even if errors occurred if zip_f: zip_f.close() self.logger.debug(f"ZIP file '{backup_full_path}' closed.") # Clean up potentially empty/failed ZIP file # Check if file exists and if any files were actually added if os.path.exists(backup_full_path) and files_added == 0: self.logger.warning(f"Backup ZIP is empty: {backup_full_path}") try: # Attempt to remove the empty zip file os.remove(backup_full_path) self.logger.info("Removed empty backup ZIP file.") except OSError as rm_e: # Log error if removal fails self.logger.error(f"Failed remove empty backup ZIP: {rm_e}") def manual_backup(self): """Handles the 'Backup Now' button click.""" self.logger.info("--- Action: Manual Backup Now ---") profile = self.main_frame.profile_var.get() if not profile: self.logger.warning("Manual Backup: No profile selected.") self.main_frame.show_error("Backup Error", "No profile selected.") return # Validate SVN Path svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})") if not svn_path: return # Validation failed # Save current settings first (especially backup dir and exclusions) self.logger.info("Saving settings before manual backup...") if not self.save_profile_settings(): self.logger.error("Manual Backup: Could not save settings.") # Ask user if they want to continue with potentially old settings if not self.main_frame.ask_yes_no( "Save Error", "Could not save current settings (e.g., exclusions).\n" "Continue backup with previously saved settings?" ): self.logger.warning("Manual backup aborted by user.") return # Call the create_backup method (which handles ZIP creation) self.logger.info(f"Starting manual backup for profile '{profile}'...") success = self.create_backup(svn_path, profile) # Show result message to the user if success: self.main_frame.show_info("Backup Complete", "Manual ZIP backup completed successfully.") else: # Error message should have been shown by create_backup self.logger.error(f"Manual backup failed for profile '{profile}'.") # Optionally show a redundant message here if needed # --- Tag Management Methods --- def refresh_tag_list(self): """Fetches tags with subjects and updates the GUI listbox.""" self.logger.info("--- Action: Refresh Tag List ---") svn_path = self._get_and_validate_svn_path("Refresh Tags") if not svn_path: # Clear list if path invalid if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([]) return # Check if repository is prepared (Git commands require .git) git_dir_path = os.path.join(svn_path, ".git") if not os.path.exists(git_dir_path): self.logger.warning("Cannot refresh tags: Repository not prepared.") # Clear list and potentially show warning? UI state should reflect. if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([]) return # Fetch tags and update GUI try: # list_tags now 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 found).") except Exception as e: # Catch potential errors from list_tags or GUI update self.logger.error(f"Failed to retrieve or update tag list: {e}", exc_info=True) self.main_frame.show_error("Error", f"Could not refresh tags:\n{e}") if hasattr(self, 'main_frame'): # Clear list on error self.main_frame.update_tag_list([]) def create_tag(self): """Handles 'Create Tag': commits (if needed), shows dialog, creates tag.""" self.logger.info("--- Action: 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.logger.error("Create Tag: No profile selected.") self.main_frame.show_error("Error", "No profile selected.") return # Save current settings (especially commit message) before proceeding if not self.save_profile_settings(): self.logger.warning("Create Tag: Could not save settings first.") # Ask user? # --- Step 1: Check for changes and commit IF commit message provided --- try: has_changes = self.git_commands.git_status_has_changes(svn_path) if has_changes: self.logger.info("Uncommitted changes detected before tagging.") # Get commit message from the dedicated GUI field commit_msg = self.main_frame.commit_message_var.get().strip() if not commit_msg: # Block tag creation if changes exist but no message self.logger.error( "Create Tag blocked: Changes exist but no commit message." ) self.main_frame.show_error( "Commit Required", "Uncommitted changes exist.\nPlease enter a commit message " "in the 'Commit / Tag Management' section and try again." ) return # Stop the process # Confirm commit with the user using the provided message confirm_commit_msg = ( f"Commit current changes with message:\n'{commit_msg}'?" ) if not self.main_frame.ask_yes_no("Confirm Commit", confirm_commit_msg): self.logger.info("User cancelled commit before tagging.") self.main_frame.show_warning("Cancelled", "Tag creation cancelled.") return # Stop tagging # Perform the commit commit_made = self.git_commands.git_commit(svn_path, commit_msg) if commit_made: self.logger.info("Pre-tag commit successful.") # Clear the commit message box after successful commit? # self.main_frame.commit_message_var.set("") # else: git_commit logs 'nothing to commit', proceed anyway else: # No changes, proceed directly to getting tag info self.logger.info("No uncommitted changes detected before tagging.") except (GitCommandError, ValueError) as e: # Handle errors during status check or commit self.logger.error(f"Error committing before tag: {e}") self.main_frame.show_error("Commit Error", f"Failed commit:\n{e}") return # Stop if commit failed except Exception as e: # Handle unexpected errors self.logger.exception(f"Unexpected pre-tag commit error: {e}") self.main_frame.show_error("Error", f"Unexpected commit error:\n{e}") return # --- Step 2: Open Dialog for Tag Name and Tag Message --- self.logger.debug("Opening create tag dialog...") # Use the custom dialog from gui.py dialog = CreateTagDialog(self.master) # Parent is the main Tk window tag_info = dialog.result # Returns tuple (name, message) or None # --- Step 3: Create Tag if user confirmed dialog --- if tag_info: tag_name, tag_message = tag_info self.logger.info(f"User wants tag: '{tag_name}', msg: '{tag_message}'") try: # Execute tag creation command self.git_commands.create_tag(svn_path, tag_name, tag_message) self.logger.info(f"Tag '{tag_name}' created successfully.") self.main_frame.show_info("Success", f"Tag '{tag_name}' created.") # Refresh list to show the new tag self.refresh_tag_list() except (GitCommandError, ValueError) as e: # Handle known errors (tag exists, invalid name) self.logger.error(f"Failed create tag '{tag_name}': {e}") self.main_frame.show_error("Tag Error", f"Could not create tag:\n{e}") except Exception as e: # Handle unexpected errors during tag creation self.logger.exception(f"Unexpected error creating tag: {e}") self.main_frame.show_error("Error", f"Unexpected tag error:\n{e}") else: # User cancelled the tag input dialog self.logger.info("Tag creation cancelled by user in dialog.") def checkout_tag(self): """Handles the 'Checkout Selected Tag' action.""" self.logger.info("--- Action: Checkout Tag ---") svn_path = self._get_and_validate_svn_path("Checkout Tag") if not svn_path: return # Get selected tag name from the listbox selected_tag = self.main_frame.get_selected_tag() # Gets only the name if not selected_tag: self.logger.warning("Checkout Tag: No tag selected.") self.main_frame.show_error("Selection Error", "Select a tag.") return self.logger.info(f"Attempting checkout for tag: {selected_tag}") # CRITICAL CHECK: Ensure no uncommitted changes before checkout try: has_changes = self.git_commands.git_status_has_changes(svn_path) if has_changes: self.logger.error("Checkout blocked: Uncommitted changes exist.") self.main_frame.show_error( "Checkout Blocked", "Uncommitted changes exist.\nCommit or stash first." ) return # Prevent checkout self.logger.debug("No uncommitted changes found.") except (GitCommandError, ValueError) as e: # Handle errors during status check self.logger.error(f"Status check error before checkout: {e}") self.main_frame.show_error("Status Error", f"Cannot check status:\n{e}") return except Exception as e: # Handle unexpected errors during status check self.logger.exception(f"Unexpected status check error: {e}") self.main_frame.show_error("Error", f"Unexpected status error:\n{e}") return # CONFIRMATION dialog with warnings confirm_msg = ( f"Checkout tag '{selected_tag}'?\n\n" f"WARNINGS:\n" f"- Files WILL BE OVERWRITTEN.\n" f"- NO backup created.\n" f"- Enters 'detached HEAD' state." ) if not self.main_frame.ask_yes_no("Confirm Checkout", confirm_msg): self.logger.info("Tag checkout cancelled by user.") return # Proceed with checkout after confirmation self.logger.info(f"User confirmed checkout for tag: {selected_tag}") # Save profile settings before potentially changing repo state? Optional. if not self.save_profile_settings(): self.logger.warning("Checkout Tag: Could not save profile settings.") # Decide whether to stop or proceed try: # Execute checkout command checkout_success = self.git_commands.checkout_tag(svn_path, selected_tag) if checkout_success: self.logger.info(f"Tag '{selected_tag}' checked out.") self.main_frame.show_info( "Checkout Successful", f"Checked out tag '{selected_tag}'.\n\n" f"NOTE: In 'detached HEAD' state.\n" f"Use 'git switch -' or checkout branch." ) # TODO: Consider updating UI state to reflect detached HEAD? # e.g., disable 'Create Tag' button? except (GitCommandError, ValueError) as e: # Handle known errors like tag not found self.logger.error(f"Failed checkout tag '{selected_tag}': {e}") self.main_frame.show_error("Checkout Error", f"Could not checkout:\n{e}") except Exception as e: # Handle unexpected errors during checkout self.logger.exception(f"Unexpected checkout error: {e}") self.main_frame.show_error("Error", f"Unexpected checkout 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 Repository 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 frame fields mf.commit_message_var.set("") mf.autocommit_var.set(False) mf.update_tag_list([]) # Clear tag listbox # Backup frame fields might retain values or load defaults # Reset SVN indicator and dependent buttons # This handles Prepare, EditGitignore, Commit/Tag widget states self.update_svn_status_indicator("") # Disable general action buttons separately 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 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 are handled by update_svn_status_indicator. """ 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 widget states 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 before the app potentially exits.""" 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 during error display 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}") # --- Application Entry Point --- def main(): """Main function: Creates Tkinter root and runs the application.""" root = tk.Tk() # Adjust min size for the new layout root.minsize(700, 700) # Increased height needed for Commit/Tag area app = None # Initialize app variable try: app = GitSvnSyncApp(root) # Start main loop only if initialization likely succeeded # A more robust check might involve a flag set at the end of __init__ 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: # Log error related to showing the message box 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__": main()