# GitTool.py import os import shutil import datetime import tkinter as tk from tkinter import messagebox # Keep messagebox import here # No need for configparser here if all access is via ConfigManager import logging # Import application modules # Import specific constants if needed directly, otherwise access via instances from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR# DEFAULT_BACKUP_DIR accessed via config_manager from git_commands import GitCommands, GitCommandError from logger_config import setup_logger # TextHandler is used internally from gui import MainFrame # Constants for the application logic (if any) can go here # GUI related constants like colors are better placed in gui.py class GitSvnSyncApp: """ Main application class for the Git SVN Sync Tool. Coordinates the GUI, configuration loading/saving, and Git command execution. Acts as the controller in a Model-View-Controller like pattern. """ 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) --- # Configure basic logging 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") # Use the name defined in logger_config # --- Configuration Manager --- # Initialize ConfigManager (passes its own logger reference initially) 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}\nApplication cannot continue.") master.destroy() # Close the application return # Stop initialization # --- GUI Main Frame --- # Create the main GUI frame, passing required callbacks and initial data try: self.main_frame = MainFrame( master, load_profile_settings_cb=self.load_profile_settings, browse_folder_cb=self.browse_folder, # Re-usable callback update_svn_status_cb=self.update_svn_status_indicator, # Specific callback for status 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, # Pass instance for defaults etc. profile_sections_list=self.config_manager.get_profile_sections(), # Initial profiles add_profile_cb=self.add_profile, remove_profile_cb=self.remove_profile ) 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}\nApplication cannot continue.") master.destroy() return # --- Enhanced Logger Setup --- # Now that the GUI log widget exists, configure the full logger self.logger = setup_logger(self.main_frame.log_text) # Update ConfigManager's logger instance if setup_logger created a new one (though getLogger should return the same) self.config_manager.logger = self.logger # --- Git Commands Handler --- # Initialize GitCommands (now decoupled from GUI widgets) # It only needs the logger instance. self.git_commands = GitCommands(self.logger) # --- Initial Application State --- self.logger.info("Application initializing...") # Load settings for the initially selected profile (triggered by MainFrame init -> profile_var trace) # We might need an explicit initial load if the trace doesn't fire immediately or correctly initial_profile = self.main_frame.profile_var.get() if initial_profile: self.logger.debug(f"Initial profile selected: '{initial_profile}'. Loading settings...") # load_profile_settings should be called automatically via trace, but call explicitly if needed: # self.load_profile_settings(initial_profile) # Usually not needed due to trace else: self.logger.warning("No profile selected on startup. Disabling action buttons.") self._clear_and_disable_fields() # Clear fields and disable buttons 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. Checking for unsaved changes or performing cleanup...") # Optional: Add logic here to check for unsaved settings or running operations # if self.has_unsaved_changes(): # Example check # if messagebox.askyesno("Exit", "You have unsaved settings. Save before exiting?", parent=self.master): # self.save_profile_settings() # Save if user confirms # else: # # User chose not to save, proceed with closing # pass # elif self.is_operation_running(): # Example check # if not messagebox.askyesno("Exit", "An operation might be running. Exit anyway?", parent=self.master): # return # Abort closing self.logger.info("Application closing.") self.master.destroy() # Close the Tkinter window # --- 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}'") if not profile_name: self.logger.warning("Attempted to load settings with no profile selected.") self._clear_and_disable_fields() return if profile_name not in self.config_manager.get_profile_sections(): self.logger.error(f"Profile '{profile_name}' not found in configuration. Cannot load settings.") self.main_frame.show_error("Profile Error", f"Profile '{profile_name}' not found.") self._clear_and_disable_fields() return # Load Repository settings using ConfigManager svn_path = self.config_manager.get_profile_option(profile_name, "svn_working_copy_path", fallback="") usb_path = self.config_manager.get_profile_option(profile_name, "usb_drive_path", fallback="") bundle_name = self.config_manager.get_profile_option(profile_name, "bundle_name", fallback="") bundle_updated_name = self.config_manager.get_profile_option(profile_name, "bundle_name_updated", fallback="") autocommit_str = self.config_manager.get_profile_option(profile_name, "autocommit", fallback="False") # Load Backup settings using ConfigManager autobackup_str = self.config_manager.get_profile_option(profile_name, "autobackup", fallback="False") # Use the centrally defined DEFAULT_BACKUP_DIR as the ultimate fallback via ConfigManager backup_dir = self.config_manager.get_profile_option(profile_name, "backup_dir", fallback=DEFAULT_BACKUP_DIR) # Update GUI Elements (check if main_frame exists) if hasattr(self, 'main_frame'): # Repository Frame self.main_frame.svn_path_entry.delete(0, tk.END) self.main_frame.svn_path_entry.insert(0, svn_path) self.main_frame.usb_path_entry.delete(0, tk.END) self.main_frame.usb_path_entry.insert(0, usb_path) self.main_frame.bundle_name_entry.delete(0, tk.END) self.main_frame.bundle_name_entry.insert(0, bundle_name) self.main_frame.bundle_updated_name_entry.delete(0, tk.END) self.main_frame.bundle_updated_name_entry.insert(0, bundle_updated_name) self.main_frame.autocommit_var.set(autocommit_str.lower() == "true") # Backup Frame self.main_frame.autobackup_var.set(autobackup_str.lower() == "true") self.main_frame.backup_dir_var.set(backup_dir) self.main_frame.toggle_backup_dir() # Update backup dir entry state # Update SVN status indicator based on the loaded path self.update_svn_status_indicator(svn_path) # Enable function buttons now that a valid profile is loaded self._enable_function_buttons() self.logger.info(f"Settings loaded successfully for profile '{profile_name}'.") else: self.logger.error("Main frame not available, cannot load settings into GUI.") def save_profile_settings(self): """Saves the current GUI field values to the currently selected profile.""" profile = self.main_frame.profile_var.get() if not profile: self.logger.warning("No profile selected. Cannot save settings.") # Optionally show error: self.main_frame.show_error("Save Error", "No profile selected.") return False # Indicate failure self.logger.info(f"Saving settings for profile: '{profile}'") try: # Save Repository settings self.config_manager.set_profile_option(profile, "svn_working_copy_path", self.main_frame.svn_path_entry.get()) self.config_manager.set_profile_option(profile, "usb_drive_path", self.main_frame.usb_path_entry.get()) self.config_manager.set_profile_option(profile, "bundle_name", self.main_frame.bundle_name_entry.get()) self.config_manager.set_profile_option(profile, "bundle_name_updated", self.main_frame.bundle_updated_name_entry.get()) self.config_manager.set_profile_option(profile, "autocommit", str(self.main_frame.autocommit_var.get())) # Save Backup settings self.config_manager.set_profile_option(profile, "autobackup", str(self.main_frame.autobackup_var.get())) self.config_manager.set_profile_option(profile, "backup_dir", self.main_frame.backup_dir_var.get()) # Persist changes to the configuration file self.config_manager.save_config() self.logger.info(f"Profile settings for '{profile}' saved successfully.") return True # Indicate success except Exception as e: self.logger.error(f"Error saving settings for profile '{profile}': {e}", exc_info=True) self.main_frame.show_error("Save Error", f"Failed to save settings for profile '{profile}'.\n{e}") return False # Indicate failure def add_profile(self): """Handles adding a new profile.""" self.logger.debug("'Add Profile' button clicked.") new_profile_name = self.main_frame.ask_new_profile_name() # Get name from GUI dialog if not new_profile_name: self.logger.info("Profile addition cancelled by user.") return new_profile_name = new_profile_name.strip() # Remove leading/trailing whitespace if not new_profile_name: self.logger.warning("Attempted to add profile with empty name.") self.main_frame.show_error("Error", "Profile name cannot be empty.") return if new_profile_name in self.config_manager.get_profile_sections(): self.logger.warning(f"Attempted to add existing profile name: '{new_profile_name}'") self.main_frame.show_error("Error", f"Profile name '{new_profile_name}' already exists.") return self.logger.info(f"Adding new profile: '{new_profile_name}'") try: # Add section and set default options using ConfigManager # ConfigManager.set_profile_option automatically adds the section if it doesn't exist self.config_manager.set_profile_option(new_profile_name, "svn_working_copy_path", "") self.config_manager.set_profile_option(new_profile_name, "usb_drive_path", "") # Use profile name in default bundle names for clarity self.config_manager.set_profile_option(new_profile_name, "bundle_name", f"{new_profile_name}_repo.bundle") self.config_manager.set_profile_option(new_profile_name, "bundle_name_updated", f"{new_profile_name}_update.bundle") self.config_manager.set_profile_option(new_profile_name, "autobackup", "False") # Use the central default backup dir constant via ConfigManager's internal logic if needed, or set explicitly self.config_manager.set_profile_option(new_profile_name, "backup_dir", DEFAULT_BACKUP_DIR) self.config_manager.set_profile_option(new_profile_name, "autocommit", "False") # Save the configuration file with the new profile self.config_manager.save_config() # Update GUI dropdown updated_sections = self.config_manager.get_profile_sections() self.main_frame.update_profile_dropdown(updated_sections) # Automatically select the newly added profile self.main_frame.profile_var.set(new_profile_name) # Loading settings for the new profile will be triggered by the profile_var trace 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 '{new_profile_name}'.\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 to remove profile when none is selected.") self.main_frame.show_error("Error", "No profile selected to remove.") return 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 if self.main_frame.ask_yes_no("Remove Profile", f"Are you sure you want to permanently remove profile '{profile_to_remove}'?"): self.logger.info(f"Attempting to remove profile: '{profile_to_remove}'") try: success = self.config_manager.remove_profile_section(profile_to_remove) if success: self.config_manager.save_config() # Save changes after successful removal self.logger.info(f"Profile '{profile_to_remove}' removed successfully.") # Update dropdown - GUI method handles selection logic updated_sections = self.config_manager.get_profile_sections() self.main_frame.update_profile_dropdown(updated_sections) # Loading settings for the new selection will be triggered by trace else: # ConfigManager already logged the specific reason (e.g., not found, error) self.main_frame.show_error("Error", f"Failed to remove profile '{profile_to_remove}'. Check 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 while removing profile '{profile_to_remove}'.\n{e}") else: self.logger.info("Profile removal cancelled by user.") # --- GUI Interaction Callbacks --- def browse_folder(self, entry_widget): """ Opens a folder selection dialog and updates the specified Tkinter Entry widget. """ self.logger.debug(f"Browse folder requested for widget: {entry_widget}") # Suggest initial directory based on current entry content, fallback to home dir initial_dir = entry_widget.get() or os.path.expanduser("~") if not os.path.isdir(initial_dir): # Handle case where entry has invalid path initial_dir = os.path.expanduser("~") directory = filedialog.askdirectory( initialdir=initial_dir, title="Select Directory", parent=self.master # Make dialog modal to main window ) if directory: # If a directory was selected (not cancelled) self.logger.debug(f"Directory selected: {directory}") entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) # If the SVN path entry was the one updated, trigger status check if entry_widget == self.main_frame.svn_path_entry: self.update_svn_status_indicator(directory) else: self.logger.debug("Folder browse dialog cancelled.") def update_svn_status_indicator(self, svn_path): """ Checks if the given SVN path is prepared for Git (contains .git) and updates the GUI indicator. Also enables/disables the 'Prepare' button. """ is_prepared = False if svn_path and os.path.isdir(svn_path): # Check if path is a valid directory git_dir_path = os.path.join(svn_path, ".git") is_prepared = os.path.exists(git_dir_path) self.logger.debug(f"Checking SVN status for path '{svn_path}'. '.git' found: {is_prepared}") else: self.logger.debug(f"SVN path '{svn_path}' is invalid or empty. Status set to unprepared.") # Update the visual indicator and button state via the MainFrame method if hasattr(self, 'main_frame'): self.main_frame.update_svn_indicator(is_prepared) # --- Core Functionality Methods --- def _get_and_validate_svn_path(self, operation_name="Operation"): """ Retrieves the SVN path from the GUI entry, validates its existence as a directory. Shows errors in the GUI and logs issues. Args: operation_name (str): Name of the operation requesting the path (for logging). Returns: str: The validated absolute path if valid, None otherwise. """ svn_path_str = self.main_frame.svn_path_entry.get().strip() if not svn_path_str: self.logger.error(f"{operation_name}: SVN Working Copy Path is empty.") self.main_frame.show_error("Input Error", "Please specify the SVN Working Copy Path.") return None abs_path = os.path.abspath(svn_path_str) if not os.path.isdir(abs_path): self.logger.error(f"{operation_name}: Specified SVN path is not a valid directory: {abs_path}") self.main_frame.show_error("Input Error", f"The specified SVN path is not a valid directory:\n{abs_path}") return None self.logger.debug(f"{operation_name}: Using validated SVN path: {abs_path}") return abs_path def _get_and_validate_usb_path(self, operation_name="Operation"): """ Retrieves the USB/Bundle Target path, validates it exists as a directory. Shows errors and logs issues. Args: operation_name (str): Name of the operation requesting the path. Returns: str: The validated absolute path if valid, None otherwise. """ usb_path_str = self.main_frame.usb_path_entry.get().strip() if not usb_path_str: self.logger.error(f"{operation_name}: Bundle Target Directory path is empty.") self.main_frame.show_error("Input Error", "Please specify the Bundle Target Directory (e.g., USB drive path).") return None abs_path = os.path.abspath(usb_path_str) if not os.path.isdir(abs_path): self.logger.error(f"{operation_name}: Specified Bundle Target path is not a valid directory: {abs_path}") self.main_frame.show_error("Input Error", f"The specified Bundle Target path is not a valid directory:\n{abs_path}") return None self.logger.debug(f"{operation_name}: Using 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 ---") # 1. Validate SVN Path svn_path = self._get_and_validate_svn_path("Prepare SVN") if not svn_path: return # Validation failed, error shown by validator # 2. Save current settings (user might have changed path) if not self.save_profile_settings(): self.logger.warning("Prepare SVN: Could not save profile settings before proceeding.") # Decide whether to continue or stop if saving fails # return # Uncomment to stop if saving fails # 3. Check if already prepared (redundant with indicator, but safe) git_dir_path = os.path.join(svn_path, ".git") if os.path.exists(git_dir_path): self.logger.info(f"Repository at '{svn_path}' is already prepared.") self.main_frame.show_info("Already Prepared", f"The repository at:\n{svn_path}\nis already prepared for Git (contains a '.git' folder).") self.update_svn_status_indicator(svn_path) # Ensure GUI is consistent return # 4. Execute Preparation Command self.logger.info(f"Executing git preparation steps for: {svn_path}") try: # Call the decoupled GitCommands method, passing the validated path self.git_commands.prepare_svn_for_git(svn_path) # 5. Success Feedback self.logger.info("SVN repository prepared successfully.") self.main_frame.show_info("Success", f"Repository prepared successfully:\n{svn_path}") self.update_svn_status_indicator(svn_path) # Update indicator to green except (GitCommandError, ValueError) as e: # Handle errors from git_commands (includes validation errors, command errors) self.logger.error(f"Error preparing SVN repository: {e}") self.main_frame.show_error("Preparation Error", f"Failed to prepare repository:\n{svn_path}\n\nError: {e}") self.update_svn_status_indicator(svn_path) # Update indicator (likely stays red) except Exception as e: # Catch unexpected errors during the process self.logger.exception(f"Unexpected error during SVN preparation: {e}") self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred during preparation:\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 ---") # 1. Validate Paths 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 # 2. Validate Bundle Name bundle_name = self.main_frame.bundle_name_entry.get().strip() if not bundle_name: self.logger.error("Create Bundle: Bundle name cannot be empty.") self.main_frame.show_error("Input Error", "Please enter a name for the bundle file (e.g., 'my_repo.bundle').") return # Basic validation for bundle name format (optional) if not bundle_name.lower().endswith(".bundle"): self.logger.warning(f"Bundle name '{bundle_name}' does not end with '.bundle'.") # Optionally force or suggest .bundle extension # bundle_name += ".bundle" bundle_full_path = os.path.join(usb_path, bundle_name) self.logger.debug(f"Target bundle file path: {bundle_full_path}") # 3. Save current settings if not self.save_profile_settings(): self.logger.warning("Create Bundle: Could not save profile settings before proceeding.") # return # Optional: Stop if saving fails # 4. Perform Backup (if enabled) if self.main_frame.autobackup_var.get(): self.logger.info("Autobackup enabled. Starting backup...") if not self.create_backup(svn_path): self.logger.error("Bundle creation aborted due to backup failure.") # Error message shown by create_backup return # Stop the process if backup fails # 5. Perform Autocommit (if enabled) if self.main_frame.autocommit_var.get(): self.logger.info("Autocommit enabled. Checking for changes and committing if necessary...") try: # Pass validated svn_path to the decoupled git_commands methods if self.git_commands.git_status_has_changes(svn_path): self.logger.info("Changes detected, performing autocommit...") commit_made = self.git_commands.git_commit(svn_path, message="Autocommit before bundle creation") if commit_made: self.logger.info("Autocommit successful.") else: # This case (status has changes but commit does nothing) should be rare self.logger.warning("Status reported changes, but autocommit resulted in 'nothing to commit'.") else: self.logger.info("No changes detected, skipping autocommit.") except (GitCommandError, ValueError) as e: self.logger.error(f"Error during autocommit process: {e}") self.main_frame.show_error("Autocommit Error", f"Failed to check status or commit changes:\n{e}") return # Stop if autocommit fails except Exception as e: self.logger.exception(f"Unexpected error during autocommit: {e}") self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred during autocommit:\n{e}") return # 6. Create the Git Bundle self.logger.info(f"Creating Git bundle at: {bundle_full_path}") try: # Pass validated svn_path and the full bundle path self.git_commands.create_git_bundle(svn_path, bundle_full_path) # Success message (GitCommands logs details, including warning for empty bundles) # Check if the bundle file actually exists (create_git_bundle might just log warning for empty) if os.path.exists(bundle_full_path): self.logger.info("Git bundle created successfully.") self.main_frame.show_info("Success", f"Git bundle created successfully:\n{bundle_full_path}") else: # Likely the 'empty bundle' case self.logger.warning("Bundle file was not created (likely because repository had no commits).") self.main_frame.show_warning("Bundle Not Created", "Bundle file was not created.\nThis usually means the repository has no commits yet.") except (GitCommandError, ValueError) as e: # Handle errors from git_commands self.logger.error(f"Error creating Git bundle: {e}") # Check for specific known issues if needed, otherwise show generic error self.main_frame.show_error("Bundle Creation Error", f"Failed to create Git bundle:\n{e}") except Exception as e: # Catch unexpected errors self.logger.exception(f"Unexpected error during bundle creation: {e}") self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred during bundle creation:\n{e}") def fetch_from_git_bundle(self): """Handles the 'Fetch from Bundle' action.""" self.logger.info("--- Action: Fetch from Git Bundle ---") # 1. Validate Paths 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 # 2. Validate Fetch Bundle Name bundle_updated_name = self.main_frame.bundle_updated_name_entry.get().strip() if not bundle_updated_name: self.logger.error("Fetch Bundle: Fetch bundle name cannot be empty.") self.main_frame.show_error("Input Error", "Please enter the name of the bundle file to fetch from.") return bundle_full_path = os.path.join(usb_path, bundle_updated_name) self.logger.debug(f"Source bundle file path: {bundle_full_path}") # 3. Check if Bundle File Exists if not os.path.isfile(bundle_full_path): # Check if it's specifically a file self.logger.error(f"Fetch Bundle: Bundle file does not exist or is not a file: '{bundle_full_path}'") self.main_frame.show_error("File Not Found", f"The specified bundle file does not exist:\n{bundle_full_path}") return # 4. Save current settings if not self.save_profile_settings(): self.logger.warning("Fetch Bundle: Could not save profile settings before proceeding.") # return # Optional: Stop if saving fails # 5. Perform Backup (if enabled) if self.main_frame.autobackup_var.get(): self.logger.info("Autobackup enabled. Starting backup...") if not self.create_backup(svn_path): self.logger.error("Fetch/merge aborted due to backup failure.") # Error message shown by create_backup return # Stop the process # 6. Fetch and Merge from Bundle self.logger.info(f"Fetching/merging into '{svn_path}' from bundle: {bundle_full_path}") try: # Pass validated svn_path and the full bundle path to the decoupled command self.git_commands.fetch_from_git_bundle(svn_path, bundle_full_path) # Success message (GitCommands logs details) self.logger.info("Changes fetched and merged successfully.") self.main_frame.show_info("Success", f"Changes fetched and merged successfully into:\n{svn_path}\nfrom bundle:\n{bundle_full_path}") except GitCommandError as e: # Handle specific errors from fetch/merge process self.logger.error(f"Error fetching/merging from Git bundle: {e}") # Check for common specific errors like merge conflicts if "merge conflict" in str(e).lower(): # Provide specific guidance for conflicts self.main_frame.show_error( "Merge Conflict", f"Merge conflict occurred while applying changes from the bundle.\n\n" f"Please resolve the conflicts manually in the repository:\n{svn_path}\n\n" f"After resolving, run 'git add .' and 'git commit' in that directory.\n\n" f"Original Error: {e}" ) else: # Show generic error for other Git command failures self.main_frame.show_error("Fetch/Merge Error", f"Failed to fetch or merge from bundle:\n{e}") except ValueError as e: # Handle validation errors (e.g., invalid path passed unexpectedly) self.logger.error(f"Validation error during fetch/merge: {e}") self.main_frame.show_error("Input Error", f"Invalid input during fetch/merge:\n{e}") except Exception as e: # Catch unexpected errors self.logger.exception(f"Unexpected error during fetch/merge: {e}") self.main_frame.show_error("Unexpected Error", f"An unexpected error occurred during fetch/merge:\n{e}") def create_backup(self, source_repo_path): """ Creates a timestamped backup of the source repository directory. Uses settings from the GUI (backup directory). Ignores the '.git' folder. Args: source_repo_path (str): The absolute path to the repository to back up. Returns: bool: True if backup was successful or not needed, False if it failed. """ # This method assumes source_repo_path is already validated # Check if backup is enabled in the first place (redundant check, but safe) if not self.main_frame.autobackup_var.get(): self.logger.debug("Backup skipped: Autobackup is disabled.") return True # Not an error, just skipped # Get backup destination directory from GUI backup_base_dir = self.main_frame.backup_dir_var.get().strip() if not backup_base_dir: self.logger.error("Backup failed: Backup directory is not specified in the settings.") self.main_frame.show_error("Backup Error", "Backup directory is not specified.\nPlease configure it in the Backup section.") return False # Ensure the base backup directory exists if not os.path.isdir(backup_base_dir): self.logger.info(f"Backup directory '{backup_base_dir}' does not exist. Attempting to create...") try: os.makedirs(backup_base_dir, exist_ok=True) # exist_ok=True prevents error if it already exists self.logger.info(f"Backup directory created or already exists: '{backup_base_dir}'") except OSError as e: self.logger.error(f"Could not create backup directory '{backup_base_dir}': {e}", exc_info=True) self.main_frame.show_error("Backup Error", f"Could not create backup directory:\n{backup_base_dir}\nError: {e}") return False # --- Create timestamped backup folder name --- now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") # Get the repository folder name from the source path repo_name = os.path.basename(os.path.normpath(source_repo_path)) or "repository" backup_folder_name = f"{repo_name}_backup_{now}" backup_full_path = os.path.join(backup_base_dir, backup_folder_name) self.logger.info(f"Creating backup of '{source_repo_path}' to '{backup_full_path}'...") try: # --- Perform the copy --- # ignore=shutil.ignore_patterns('.git') prevents copying the git internal dir shutil.copytree( source_repo_path, backup_full_path, symlinks=False, # Decide if you need symlinks copied ignore=shutil.ignore_patterns('.git', '.svn') # Ignore both .git and .svn internal dirs ) self.logger.info(f"Backup created successfully: {backup_full_path}") return True # Backup succeeded except OSError as e: # Catch specific OS errors during copy (permissions, disk full, etc.) self.logger.error(f"OS error creating backup: {e}", exc_info=True) self.main_frame.show_error("Backup Error", f"Error creating backup copy:\n{e}") return False except Exception as e: # Catch any other unexpected errors during the copy process self.logger.exception(f"Unexpected error during backup copytree: {e}") self.main_frame.show_error("Backup Error", f"An unexpected error occurred during backup:\n{e}") return False # --- GUI State Utilities --- def _clear_and_disable_fields(self): """Clears repository config fields and disables action buttons.""" if hasattr(self, 'main_frame'): # Clear repository fields self.main_frame.svn_path_entry.delete(0, tk.END) self.main_frame.usb_path_entry.delete(0, tk.END) self.main_frame.bundle_name_entry.delete(0, tk.END) self.main_frame.bundle_updated_name_entry.delete(0, tk.END) self.main_frame.autocommit_var.set(False) # Clear backup fields (optional, might keep defaults) # self.main_frame.autobackup_var.set(False) # self.main_frame.backup_dir_var.set("") # self.main_frame.toggle_backup_dir() # Disable action buttons self._disable_function_buttons() # Reset SVN indicator self.update_svn_status_indicator("") # Pass empty path to show unprepared self.logger.debug("GUI fields cleared and action buttons disabled.") def _disable_function_buttons(self): """Disables the main action buttons (Prepare, Create, Fetch).""" if hasattr(self, 'main_frame'): if hasattr(self.main_frame, 'prepare_svn_button'): self.main_frame.prepare_svn_button.config(state=tk.DISABLED) if hasattr(self.main_frame, 'create_bundle_button'): self.main_frame.create_bundle_button.config(state=tk.DISABLED) if hasattr(self.main_frame, 'fetch_bundle_button'): self.main_frame.fetch_bundle_button.config(state=tk.DISABLED) self.logger.debug("Function buttons disabled.") def _enable_function_buttons(self): """Enables the Create and Fetch buttons. Prepare button state depends on repo status.""" if hasattr(self, 'main_frame'): # Always enable Create and Fetch if a profile is loaded if hasattr(self.main_frame, 'create_bundle_button'): self.main_frame.create_bundle_button.config(state=tk.NORMAL) if hasattr(self.main_frame, 'fetch_bundle_button'): self.main_frame.fetch_bundle_button.config(state=tk.NORMAL) # Prepare button state is handled by update_svn_indicator based on path check self.update_svn_status_indicator(self.main_frame.svn_path_entry.get()) self.logger.debug("Create/Fetch buttons enabled. Prepare button state updated based on status.") def show_fatal_error(self, message): """Shows a fatal error message before the app potentially exits.""" messagebox.showerror("Fatal Error", message) # --- Application Entry Point --- def main(): """Main function to create the Tkinter root window and run the application.""" root = tk.Tk() # Optional: Set minimum size for the window root.minsize(600, 500) try: app = GitSvnSyncApp(root) # Only run mainloop if app initialized successfully if app: # Basic check if constructor returned something (didn't hit early return) root.mainloop() except Exception as e: # Catch-all for truly unexpected errors during App init that weren't handled internally logging.exception("Fatal error during application startup or main loop.") messagebox.showerror("Fatal Error", f"Application failed unexpectedly:\n{e}") if __name__ == "__main__": main()