diff --git a/GitTool.py b/GitTool.py deleted file mode 100644 index 21d7154..0000000 --- a/GitTool.py +++ /dev/null @@ -1,1324 +0,0 @@ -# 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() \ No newline at end of file diff --git a/GitUtility.ico b/GitUtility.ico new file mode 100644 index 0000000..65df318 Binary files /dev/null and b/GitUtility.ico differ diff --git a/GitUtility.py b/GitUtility.py index ca8b97c..c1abdec 100644 --- a/GitUtility.py +++ b/GitUtility.py @@ -1,10 +1,12 @@ # 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 +# import zipfile # Not needed anymore # Import application modules from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR @@ -63,7 +65,7 @@ class GitSvnSyncApp: self.main_frame = MainFrame( master, load_profile_settings_cb=self.load_profile_settings, - browse_folder_cb=self.browse_folder, + # 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, @@ -132,10 +134,8 @@ class GitSvnSyncApp: return None if not hasattr(self.main_frame, 'svn_path_entry'): self.logger.error(f"{operation_name}: SVN path widget missing.") - # Try showing error if main_frame exists, otherwise just log if hasattr(self, 'main_frame'): - self.main_frame.show_error("Internal Error", - "SVN Path widget not found.") + self.main_frame.show_error("Internal Error", "SVN Path widget missing.") return None svn_path_str = self.main_frame.svn_path_entry.get() @@ -147,13 +147,8 @@ class GitSvnSyncApp: 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}" - ) + 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}") @@ -168,32 +163,72 @@ class GitSvnSyncApp: 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 not found.") + 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 Directory cannot be 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 directory: {abs_path}" - ) - self.main_frame.show_error( - "Input Error", - f"Invalid Bundle Target path (not a directory):\n{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): @@ -207,15 +242,14 @@ class GitSvnSyncApp: 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 '{profile_name}'.") + 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 Repository Frame widgets + # 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) @@ -224,10 +258,8 @@ class GitSvnSyncApp: 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", "")) - # Update Commit/Tag Frame widgets mf.autocommit_var.set(profile_data.get("autocommit", False)) # Bool mf.commit_message_var.set(profile_data.get("commit_message", "")) - # Update Backup Frame widgets 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( @@ -264,24 +296,24 @@ class GitSvnSyncApp: profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Save Error", "No profile selected.") - return # Don't proceed + 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 + return False # Delegate saving to ProfileHandler success = self.profile_handler.save_profile_data(profile, current_data) if success: - # Give positive feedback self.main_frame.show_info("Saved", f"Settings saved for '{profile}'.") + return True else: # Error message likely shown by handler/save method - self.logger.error(f"Saving settings failed for profile '{profile}'.") - # self.main_frame.show_error("Save Error", "Failed to save settings.") + # self.main_frame.show_error("Save Error", f"Failed save settings.") + return False def _get_data_from_gui(self): @@ -292,17 +324,16 @@ class GitSvnSyncApp: mf = self.main_frame # Read values from all relevant widgets/variables - data = { - "svn_working_copy_path": mf.svn_path_entry.get(), - "usb_drive_path": mf.usb_path_entry.get(), - "bundle_name": mf.bundle_name_entry.get(), - "bundle_name_updated": mf.bundle_updated_name_entry.get(), - "autocommit": mf.autocommit_var.get(), # Gets boolean - "commit_message": mf.commit_message_var.get(), - "autobackup": mf.autobackup_var.get(), # Gets boolean - "backup_dir": mf.backup_dir_var.get(), - "backup_exclude_extensions": mf.backup_exclude_extensions_var.get() - } + 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 @@ -315,19 +346,16 @@ class GitSvnSyncApp: new_name = new_name.strip() if not new_name: - self.main_frame.show_error("Error", "Profile name cannot be empty."); return + self.main_frame.show_error("Error", "Profile name empty."); return # Delegate adding logic success = self.profile_handler.add_new_profile(new_name) - if success: - # Update GUI dropdown and select the new profile 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: - # Handler logged the reason (exists or error) self.main_frame.show_error("Error", f"Could not add profile '{new_name}'.") @@ -350,10 +378,9 @@ class GitSvnSyncApp: self.main_frame.update_profile_dropdown(sections) # Triggers load self.main_frame.show_info("Removed", f"Profile '{profile}' removed.") else: - # Handler logged the reason self.main_frame.show_error("Error", f"Failed to remove '{profile}'.") else: - self.logger.info("Profile removal cancelled.") + self.logger.info("Removal cancelled.") # --- GUI Interaction Wrappers --- @@ -369,7 +396,7 @@ class GitSvnSyncApp: self.logger.debug(f"Selected: {directory}") entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) - # Update status if SVN path changed + # Trigger status update if SVN path changed if entry_widget == self.main_frame.svn_path_entry: self.update_svn_status_indicator(directory) else: @@ -385,10 +412,10 @@ class GitSvnSyncApp: if hasattr(self, 'main_frame'): mf = self.main_frame # Alias - # Update indicator & Prepare button via MainFrame method + # Update indicator & Prepare button via GUI method mf.update_svn_indicator(is_ready) - # Determine states for other widgets based on validity/readiness + # 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 @@ -425,7 +452,7 @@ class GitSvnSyncApp: """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 # Stop if path invalid + if not svn_path: return gitignore_path = os.path.join(svn_path, ".gitignore") self.logger.debug(f"Target .gitignore path: {gitignore_path}") @@ -449,9 +476,12 @@ class GitSvnSyncApp: 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 save settings first.") - # Ask user? + 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: @@ -491,7 +521,7 @@ class GitSvnSyncApp: # Ensure .bundle extension if not bundle_name.lower().endswith(".bundle"): bundle_name += ".bundle" - mf = self.main_frame + 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) @@ -669,24 +699,26 @@ class GitSvnSyncApp: # 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 cannot be empty.") + self.main_frame.show_error("Commit Error", "Commit message empty.") return - # Save settings first? Optional, but saves the message if typed. + # 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 if they want to continue? + # Ask user? # Delegate commit execution to ActionHandler try: - commit_made = self.action_handler.execute_manual_commit(svn_path, commit_msg) + commit_made = self.action_handler.execute_manual_commit( + svn_path, commit_msg + ) if commit_made: self.main_frame.show_info("Success", "Changes committed.") - # Optionally clear message field after successful commit + # 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 were detected 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}") @@ -708,10 +740,10 @@ class GitSvnSyncApp: self.main_frame.update_tag_list([]) # Clear list return + # Fetch tags and update GUI try: - # Get tag data (list of tuples) from GitCommands + # list_tags returns list of tuples (name, subject) tags_data = self.git_commands.list_tags(svn_path) - # Update the GUI listbox if hasattr(self, 'main_frame'): self.main_frame.update_tag_list(tags_data) self.logger.info(f"Tag list updated ({len(tags_data)} tags).") @@ -729,9 +761,10 @@ class GitSvnSyncApp: 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 + self.main_frame.show_error("Error", "No profile selected.") + return - # Get commit message from GUI (for potential pre-commit) + # 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) @@ -739,32 +772,40 @@ class GitSvnSyncApp: self.logger.warning("Create Tag: Could not save settings first.") # Ask user? - # Open Dialog first to get Tag Name and Tag Message + # --- Open Dialog to get Tag Name and Tag Message --- self.logger.debug("Opening create tag dialog...") - dialog = CreateTagDialog(self.master) + 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: - self.logger.info("Tag creation cancelled in dialog."); return + # 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 (including potential pre-commit) to ActionHandler + # --- 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 ) - if success: + # 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 - except ValueError as e: # Catch specific errors like "commit message required" + 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 (commit or tag) + 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 + 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}") @@ -777,7 +818,8 @@ class GitSvnSyncApp: 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.main_frame.show_error("Selection Error", "Select a tag.") + return self.logger.info(f"Attempting checkout for tag: {selected_tag}") @@ -790,7 +832,7 @@ class GitSvnSyncApp: # Save settings before action? Optional. if not self.ui_save_settings(): - self.logger.warning("Checkout Tag: Could not save settings.") + self.logger.warning("Checkout Tag: Could not save profile settings.") # Delegate execution to ActionHandler try: @@ -798,7 +840,7 @@ class GitSvnSyncApp: if success: self.main_frame.show_info("Success", f"Checked out tag '{selected_tag}'.\n\nNOTE: In 'detached HEAD'.") - # Update display after successful checkout + # 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)) @@ -820,7 +862,7 @@ class GitSvnSyncApp: self.main_frame.show_error("Selection Error", "Select a tag to delete.") return - # Confirmation + # 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.") @@ -856,8 +898,10 @@ class GitSvnSyncApp: # 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: @@ -871,22 +915,25 @@ class GitSvnSyncApp: """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 + 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) - # Handle return values from get_current_branch + # Update display text based on result if branch_name == "(DETACHED HEAD)": - current_branch_name = branch_name + current_branch_name = branch_name # Show detached state clearly elif branch_name == "": - current_branch_name = "" + current_branch_name = "" # Show error state elif branch_name: - current_branch_name = branch_name - else: # Should not happen if logic in get_current_branch is correct + 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 = "" @@ -912,18 +959,15 @@ class GitSvnSyncApp: # Delegate execution to ActionHandler try: - # TODO: Add start_point logic if needed later + # 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 - # Ask user if they want to switch? - # switch_q = f"Switch to new branch '{new_branch_name}'?" - # if self.main_frame.ask_yes_no("Switch Branch?", switch_q): - # self.ui_switch_branch(new_branch_name) # Needs method adjustment - + # 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: @@ -941,7 +985,7 @@ class GitSvnSyncApp: if not selected_branch: self.main_frame.show_error("Error", "Select a branch."); return - # Avoid switching to the same branch + # 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}'.") @@ -952,14 +996,15 @@ class GitSvnSyncApp: # 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() - self.refresh_branch_list() # Update highlight + 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)) @@ -997,13 +1042,15 @@ class GitSvnSyncApp: # Delegate execution try: + # Attempt safe delete first success = self.action_handler.execute_delete_branch( - svn_path, selected_branch, force=False # Attempt safe delete first + 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 raises error + # else: Handler should raise error + except GitCommandError as e: # Handle specific errors, like 'not fully merged' if "not fully merged" in str(e).lower(): @@ -1036,8 +1083,8 @@ class GitSvnSyncApp: def _clear_and_disable_fields(self): """Clears relevant GUI fields and disables most buttons.""" if hasattr(self, 'main_frame'): - mf = self.main_frame - # Clear Repo 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) @@ -1045,12 +1092,13 @@ class GitSvnSyncApp: # Clear Commit/Tag/Branch frame fields mf.commit_message_var.set("") mf.autocommit_var.set(False) - mf.update_tag_list([]) - mf.update_branch_list([]) + mf.update_tag_list([]) # Clear tag listbox + mf.update_branch_list([]) # Clear branch listbox mf.set_current_branch_display("") - # Reset indicator and dependent buttons - self.update_svn_status_indicator("") # Disables state-dependent - # Disable general action buttons + # 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.") @@ -1058,7 +1106,7 @@ class GitSvnSyncApp: def _disable_general_buttons(self): """Disables buttons generally requiring only a loaded profile.""" if hasattr(self, 'main_frame'): - # List of general button attribute names + # List of general action button attribute names in main_frame button_names = [ 'create_bundle_button', 'fetch_bundle_button', 'manual_backup_button', 'save_settings_button' @@ -1073,11 +1121,11 @@ class GitSvnSyncApp: def _enable_function_buttons(self): """ Enables general action buttons. State-dependent buttons rely on - update_svn_status_indicator. + update_svn_status_indicator for their state. """ if hasattr(self, 'main_frame'): general_state = tk.NORMAL - # List of general button attribute names + # List of general action button attribute names button_names = [ 'create_bundle_button', 'fetch_bundle_button', 'manual_backup_button', 'save_settings_button' @@ -1089,7 +1137,7 @@ class GitSvnSyncApp: button.config(state=general_state) # Ensure state-dependent buttons reflect the current status - # This call handles Prepare, EditGitignore, Commit/Tag/Branch widgets + # 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() @@ -1111,15 +1159,53 @@ class GitSvnSyncApp: 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 - # May need adjustment based on final widget sizes - root.minsize(750, 650) # Adjusted height back down slightly + 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) @@ -1150,7 +1236,7 @@ def main(): if __name__ == "__main__": # Setup basic logging immediately at startup - # This ensures logs are captured even if setup_logger fails later log_format = "%(asctime)s - %(levelname)s - [%(module)s:%(funcName)s:%(lineno)d] - %(message)s" - logging.basicConfig(level=logging.INFO, format=log_format) # Consider level=logging.DEBUG for more detail + # Consider level=logging.DEBUG for more detail during development + logging.basicConfig(level=logging.INFO, format=log_format) main() \ No newline at end of file diff --git a/GitUtility.spec b/GitUtility.spec new file mode 100644 index 0000000..3ac9cf1 --- /dev/null +++ b/GitUtility.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['GitUtility.py'], + pathex=[], + binaries=[], + datas=[('git_svn_sync.ini', '.')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='GitUtility', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='GitUtility', +) diff --git a/action_handler.py b/action_handler.py index 296991f..2333770 100644 --- a/action_handler.py +++ b/action_handler.py @@ -22,6 +22,7 @@ class ActionHandler: self.git_commands = git_commands self.backup_handler = backup_handler + def _perform_backup_if_enabled(self, svn_path, profile_name, autobackup_enabled, backup_base_dir, excluded_extensions, excluded_dirs): @@ -37,15 +38,16 @@ class ActionHandler: excluded_dirs (set): Directory names to exclude. Raises: - IOError: If the backup process fails. + IOError: If the backup process fails for any reason. """ if not autobackup_enabled: self.logger.debug("Autobackup disabled, skipping backup.") - return # Backup not needed, proceed + return # Backup not needed, proceed successfully self.logger.info("Autobackup enabled. Starting backup...") try: # Delegate backup creation to the BackupHandler instance + # It will raise exceptions on failure backup_path = self.backup_handler.create_zip_backup( svn_path, backup_base_dir, @@ -55,11 +57,12 @@ class ActionHandler: ) # Log success if backup handler doesn't raise an error self.logger.info(f"Backup completed successfully: {backup_path}") - # No explicit return value needed on success, failure indicated by exception + # No explicit return value needed on success except Exception as backup_e: # Log error and re-raise as IOError to signal critical failure self.logger.error(f"Backup failed: {backup_e}", exc_info=True) + # Standardize on IOError for backup failures passed up raise IOError(f"Autobackup failed: {backup_e}") from backup_e @@ -85,17 +88,14 @@ class ActionHandler: raise ValueError("Repository is already prepared.") # Attempt preparation using GitCommands + # Any exception (GitCommandError, ValueError, IOError) will be caught try: self.git_commands.prepare_svn_for_git(svn_path) self.logger.info("Repository prepared successfully.") return True - except (GitCommandError, ValueError, IOError) as e: - # Log and re-raise known errors for the UI layer to handle - self.logger.error(f"Failed to prepare repository: {e}", exc_info=True) - raise except Exception as e: - # Log and re-raise unexpected errors - self.logger.exception(f"Unexpected error during preparation: {e}") + # Log and re-raise any exception from prepare_svn_for_git + self.logger.error(f"Failed to prepare repository: {e}", exc_info=True) raise @@ -142,7 +142,7 @@ class ActionHandler: commit_msg_to_use = commit_message if commit_message else \ f"Autocommit '{profile_name}' before bundle" self.logger.debug(f"Using autocommit message: '{commit_msg_to_use}'") - # Perform commit (raises error on failure) + # Perform commit (raises error on failure, returns bool) self.git_commands.git_commit(svn_path, commit_msg_to_use) # Log based on return value? git_commit already logs detail. self.logger.info("Autocommit attempt finished.") @@ -172,7 +172,7 @@ class ActionHandler: try: os.remove(bundle_full_path) except OSError: - self.logger.warning(f"Could not remove empty bundle file.") + self.logger.warning("Could not remove empty bundle file.") return None # Indicate non-fatal issue (empty bundle) except Exception as bundle_e: # Log and re-raise bundle creation errors @@ -205,7 +205,7 @@ class ActionHandler: # Delegate to GitCommands; it raises GitCommandError on conflict/failure self.git_commands.fetch_from_git_bundle(svn_path, bundle_full_path) self.logger.info("Fetch/merge process completed successfully.") - # No return value needed, success indicated by no exception + # No return needed, success indicated by no exception except Exception as fetch_e: # Log and re-raise any error from fetch/merge self.logger.error(f"Fetch/merge failed: {fetch_e}", exc_info=True) @@ -221,13 +221,13 @@ class ActionHandler: commit_message (str): The commit message (must not be empty). Returns: - bool: True if commit made, False if no changes. + bool: True if a commit was made, False if no changes were committed. Raises: ValueError: If commit_message is empty. GitCommandError/Exception: If commit command fails. """ if not commit_message: - # Should be validated by caller (UI layer) + # This validation should ideally happen before calling this method self.logger.error("Manual commit attempt with empty message.") raise ValueError("Commit message cannot be empty.") @@ -274,9 +274,10 @@ class ActionHandler: # Perform the pre-tag commit with the provided message self.logger.debug(f"Performing pre-tag commit: '{commit_message}'") - # git_commit raises error on failure, returns bool on success/no changes - self.git_commands.git_commit(svn_path, commit_message) - self.logger.info("Pre-tag commit attempt finished.") + # git_commit raises error on failure, returns bool otherwise + commit_made = self.git_commands.git_commit(svn_path, commit_message) + # Log based on return value? git_commit logs details. + self.logger.info(f"Pre-tag commit attempt finished (made={commit_made}).") else: self.logger.info("No uncommitted changes detected before tagging.") @@ -303,11 +304,11 @@ class ActionHandler: def execute_checkout_tag(self, svn_path, tag_name): """ - Executes checkout for the specified tag after checking for changes. + Executes checkout for the specified tag after checking changes. Args: svn_path (str): Validated path to repository. - tag_name (str): The tag name to check out (already validated by caller). + tag_name (str): The tag name to check out (validated by caller). Returns: bool: True on successful checkout. @@ -316,7 +317,7 @@ class ActionHandler: GitCommandError/Exception: If status check or checkout fails. """ if not tag_name: - raise ValueError("Tag name required for checkout.") # Should be caught earlier + raise ValueError("Tag name required for checkout.") self.logger.info(f"Executing checkout tag '{tag_name}' in: {svn_path}") @@ -336,11 +337,15 @@ class ActionHandler: # --- Execute Checkout --- try: - # git_commands.checkout_tag raises GitCommandError on failure + # git_commands.checkout_tag raises error on failure checkout_success = self.git_commands.checkout_tag(svn_path, tag_name) - # If no exception, assume success - self.logger.info(f"Tag '{tag_name}' checked out.") - return True + if checkout_success: + self.logger.info(f"Tag '{tag_name}' checked out.") + return True + else: + # This path should theoretically not be reached + self.logger.error("Checkout command reported failure unexpectedly.") + raise GitCommandError("Checkout failed for unknown reason.") except Exception as e: # Catch errors during checkout (e.g., tag not found) self.logger.error(f"Failed to checkout tag '{tag_name}': {e}", @@ -365,6 +370,7 @@ class ActionHandler: self.git_commands.create_branch(svn_path, branch_name, start_point) return True except Exception as e: + # Log and re-raise error self.logger.error(f"Failed to create branch: {e}", exc_info=True) raise @@ -388,9 +394,10 @@ class ActionHandler: if has_changes: msg = "Uncommitted changes exist. Commit or stash first." self.logger.error(f"Switch blocked: {msg}") - raise ValueError(msg) # Raise specific error + raise ValueError(msg) # Raise specific error for UI self.logger.debug("No uncommitted changes found.") except Exception as e: + # Catch errors during status check self.logger.error(f"Status check error before switch: {e}", exc_info=True) raise # Re-raise status check errors @@ -401,6 +408,7 @@ class ActionHandler: success = self.git_commands.checkout_branch(svn_path, branch_name) return success except Exception as e: + # Catch errors during switch (e.g., branch not found) self.logger.error(f"Failed switch to branch '{branch_name}': {e}", exc_info=True) raise @@ -417,27 +425,27 @@ class ActionHandler: """ if not branch_name: raise ValueError("Branch name required for delete.") - # Add checks for main/master? Done in UI layer. + # Add checks for main/master? UI layer handles this. + self.logger.info(f"Executing delete branch '{branch_name}' (force={force}).") try: # Delegate to git_commands, raises error on failure success = self.git_commands.delete_branch(svn_path, branch_name, force) return success except Exception as e: - # Catch errors (like not fully merged if force=False) + # Catch errors (like not merged, needs force) self.logger.error(f"Failed delete branch '{branch_name}': {e}", exc_info=True) raise - # --- ADDED: Delete Tag Action --- def execute_delete_tag(self, svn_path, tag_name): """ Executes deletion for the specified tag. Args: svn_path (str): Validated path to repository. - tag_name (str): The tag name to delete (validated by caller). + tag_name (str): The tag name to delete (already validated). Returns: bool: True on successful deletion. @@ -445,7 +453,7 @@ class ActionHandler: GitCommandError/ValueError/Exception: If delete fails. """ if not tag_name: - # Should be validated by caller (UI layer) + # Should be validated by caller raise ValueError("Tag name required for deletion.") self.logger.info(f"Executing delete tag '{tag_name}' in: {svn_path}") diff --git a/create_exe.bat b/create_exe.bat new file mode 100644 index 0000000..e69de29 diff --git a/gui.py b/gui.py index d1340dd..7871bc2 100644 --- a/gui.py +++ b/gui.py @@ -26,9 +26,11 @@ class Tooltip: def showtip(self): """Display text in a tooltip window.""" - self.hidetip() # Hide any existing tooltip first + # Hide any existing tooltip first + self.hidetip() + # Avoid error if widget is destroyed before showing tooltip if not self.widget.winfo_exists(): - return # Avoid error if widget destroyed + return try: # Get widget position relative to widget itself x_rel, y_rel, _, _ = self.widget.bbox("insert") @@ -39,7 +41,7 @@ class Tooltip: x_pos = x_root + x_rel + 25 y_pos = y_root + y_rel + 25 except tk.TclError: - # Fallback position calculation if bbox fails + # Fallback position calculation if bbox fails (e.g., widget hidden) x_root = self.widget.winfo_rootx() y_root = self.widget.winfo_rooty() widget_width = self.widget.winfo_width() @@ -50,7 +52,7 @@ class Tooltip: # Create the tooltip window as a Toplevel self.tooltip_window = tk.Toplevel(self.widget) tw = self.tooltip_window - # Remove window decorations (border, title bar) + # Remove window decorations (border, title bar etc.) tw.wm_overrideredirect(True) # Position the window (ensure integer coordinates) tw.wm_geometry(f"+{int(x_pos)}+{int(y_pos)}") @@ -69,7 +71,9 @@ class Tooltip: def hidetip(self): """Hide the tooltip window.""" tw = self.tooltip_window + # Reset the reference self.tooltip_window = None + # Destroy the window if it exists if tw: try: # Check if window still exists before destroying @@ -92,14 +96,17 @@ class GitignoreEditorWindow(tk.Toplevel): # Store original content to check for changes on close self.original_content = "" - # Window Configuration - self.title(f"Edit {os.path.basename(gitignore_path)}") - self.geometry("600x450") - self.minsize(400, 300) - self.grab_set() # Make window modal - self.transient(master) # Keep window on top of parent - self.protocol("WM_DELETE_WINDOW", self._on_close) # Handle close button + # --- Window Configuration --- + base_filename = os.path.basename(gitignore_path) + self.title(f"Edit {base_filename}") + self.geometry("600x450") # Initial size + self.minsize(400, 300) # Minimum resizeable dimensions + self.grab_set() # Make window modal (grab all events) + self.transient(master) # Keep window on top of parent + # Handle closing via window manager (X button) + self.protocol("WM_DELETE_WINDOW", self._on_close) + # --- Widgets --- # Main frame with padding main_frame = ttk.Frame(self, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) @@ -129,7 +136,7 @@ class GitignoreEditorWindow(tk.Toplevel): text="Save and Close", command=self._save_and_close ) - self.save_button.grid(row=0, column=2, padx=5) # Right-center + self.save_button.grid(row=0, column=2, padx=5) # Place in right-center # Cancel button self.cancel_button = ttk.Button( @@ -137,19 +144,19 @@ class GitignoreEditorWindow(tk.Toplevel): text="Cancel", command=self._on_close ) - self.cancel_button.grid(row=0, column=1, padx=5) # Left-center + self.cancel_button.grid(row=0, column=1, padx=5) # Place in left-center - # Load initial file content + # --- Load Initial Content --- self._load_file() - # Center window relative to parent + # Center window relative to parent after creation self._center_window(master) # Set initial focus to the text editor self.text_editor.focus_set() def _center_window(self, parent): """Centers the editor window relative to its parent.""" - self.update_idletasks() # Ensure window size is calculated correctly - # Get parent window geometry + self.update_idletasks() # Process pending geometry changes + # Get parent window geometry and position parent_x = parent.winfo_rootx() parent_y = parent.winfo_rooty() parent_width = parent.winfo_width() @@ -160,65 +167,68 @@ class GitignoreEditorWindow(tk.Toplevel): # Calculate position for centering x_pos = parent_x + (parent_width // 2) - (win_width // 2) y_pos = parent_y + (parent_height // 2) - (win_height // 2) - # Prevent window going off-screen (basic check) + # Basic screen boundary check to prevent window going off-screen screen_width = self.winfo_screenwidth() screen_height = self.winfo_screenheight() x_pos = max(0, min(x_pos, screen_width - win_width)) y_pos = max(0, min(y_pos, screen_height - win_height)) - # Apply the calculated position + # Apply the calculated position using wm_geometry self.geometry(f"+{int(x_pos)}+{int(y_pos)}") def _load_file(self): """Loads the content of the .gitignore file into the editor.""" self.logger.info(f"Loading content for: {self.gitignore_path}") try: - content = "" # Default empty content + content = "" # Default to empty content if os.path.exists(self.gitignore_path): # Read file content with specified encoding and error handling with open(self.gitignore_path, 'r', encoding='utf-8', errors='replace') as f: content = f.read() - self.logger.debug(".gitignore content loaded.") + self.logger.debug(".gitignore content loaded successfully.") else: # File doesn't exist self.logger.info(f"'{self.gitignore_path}' does not exist.") - # Store original content and update editor + # Store original content and update editor text self.original_content = content - self.text_editor.delete("1.0", tk.END) # Clear previous content + self.text_editor.delete("1.0", tk.END) # Clear existing content first self.text_editor.insert(tk.END, self.original_content) - # Reset undo stack after loading new content + # Reset undo stack after programmatically changing text self.text_editor.edit_reset() except IOError as e: - # Handle file reading errors - self.logger.error(f"Read error: {e}", exc_info=True) + # Handle file reading errors specifically + self.logger.error(f"Error reading {self.gitignore_path}: {e}", + exc_info=True) messagebox.showerror( "Error Reading File", f"Could not read the .gitignore file:\n{e}", - parent=self + parent=self # Show error relative to this dialog ) except Exception as e: - # Handle other unexpected errors during loading - self.logger.exception(f"Unexpected load error: {e}") + # Handle other unexpected errors during file loading + self.logger.exception(f"Unexpected error loading file: {e}") messagebox.showerror( "Unexpected Error", - f"An unexpected error occurred loading file:\n{e}", + f"An unexpected error occurred loading the file:\n{e}", parent=self ) def _save_file(self): """Saves the current editor content to the .gitignore file.""" - # Get content, normalize whitespace and newline + # Get content from text widget, remove trailing whitespace current_content = self.text_editor.get("1.0", tk.END).rstrip() + # Add a single trailing newline if content is not empty if current_content: current_content += "\n" - # Normalize original content similarly for comparison + + # Normalize original content similarly for accurate comparison normalized_original = self.original_content.rstrip() if normalized_original: normalized_original += "\n" - # Check if content actually changed + # Check if content has actually changed if current_content == normalized_original: self.logger.info("No changes detected in .gitignore. Skipping save.") return True # Indicate success (no action needed) @@ -230,20 +240,22 @@ class GitignoreEditorWindow(tk.Toplevel): with open(self.gitignore_path, 'w', encoding='utf-8', newline='\n') as f: f.write(current_content) self.logger.info(".gitignore file saved successfully.") - # Update original content state and reset undo stack + # Update original content baseline after successful save self.original_content = current_content + # Reset undo stack after saving changes self.text_editor.edit_reset() return True # Indicate save success except IOError as e: # Handle file writing errors - self.logger.error(f"Write error: {e}", exc_info=True) + self.logger.error(f"Error writing {self.gitignore_path}: {e}", + exc_info=True) messagebox.showerror("Error Saving File", f"Could not save the .gitignore file:\n{e}", parent=self) return False # Indicate save failure except Exception as e: # Handle other unexpected errors during saving - self.logger.exception(f"Unexpected save error: {e}") + self.logger.exception(f"Unexpected error saving file: {e}") messagebox.showerror("Unexpected Error", f"An unexpected error occurred saving file:\n{e}", parent=self) @@ -276,11 +288,12 @@ class GitignoreEditorWindow(tk.Toplevel): parent=self ) if response is True: # User chose Yes (Save) - self._save_and_close() # Attempts save, closes only if successful + # Attempt save, close only if successful + self._save_and_close() elif response is False: # User chose No (Discard) self.logger.warning("Discarding unsaved changes in editor.") self.destroy() # Close immediately - # Else (response is None - Cancel): Do nothing, keep window open + # else (response is None - Cancel): Do nothing, keep window open else: # No changes detected, simply close the window self.destroy() @@ -341,7 +354,6 @@ class CreateTagDialog(simpledialog.Dialog): return 0 # Fail validation # Validate tag name format using regex (ensure 're' is imported) - # Pattern based on git check-ref-format rules pattern = r"^(?![./]|.*([./]{2,}|[.]$|\.lock$|@\{))[^ \t\n\r\f\v~^:?*[\\]+(?") # For branch display + self.current_branch_var = tk.StringVar(value="") # --- Create Main Layout Sections --- - # Profile selection is always visible at the top self._create_profile_frame() - - # --- Create Notebook for Tabs --- - # Add padding below tabs for separation from action buttons - self.notebook = ttk.Notebook(self, padding=(0, 5, 0, 0)) - self.notebook.pack(pady=5, padx=0, fill="both", expand=True) - - # --- Create Frames for each Tab --- - # Add padding within each tab frame for content spacing - self.setup_tab_frame = ttk.Frame(self.notebook, padding=(10)) - self.commit_branch_tab_frame = ttk.Frame(self.notebook, padding=(10)) - self.tags_gitignore_tab_frame = ttk.Frame(self.notebook, padding=(10)) - - # Add frames as tabs to the notebook with descriptive text - self.notebook.add(self.setup_tab_frame, text=' Setup & Backup ') # Combined setup - self.notebook.add(self.commit_branch_tab_frame, text=' Commit & Branches ') - self.notebook.add(self.tags_gitignore_tab_frame, text=' Tags & Gitignore ') - - # --- Populate Tabs with Widgets --- - self._populate_setup_tab() - self._populate_commit_branch_tab() - self._populate_tags_gitignore_tab() - - # --- Core Actions Frame (Below Tabs) --- + self._create_notebook_with_tabs() self._create_function_frame() - - # --- Log Area (Bottom) --- self._create_log_area() # --- Initial State Configuration --- self._initialize_profile_selection() - # Set initial state of backup widgets based on checkbox value self.toggle_backup_dir() @@ -519,82 +503,78 @@ class MainFrame(ttk.Frame): self.profile_frame = ttk.LabelFrame( self, text="Profile Configuration", padding=(10, 5) ) - # Pack frame at the top, below potential menu bar, expand horizontally self.profile_frame.pack(pady=(0, 5), fill="x") - # Allow dropdown column (column 1) to expand horizontally self.profile_frame.columnconfigure(1, weight=1) - # Profile Label profile_label = ttk.Label(self.profile_frame, text="Profile:") profile_label.grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) - # Profile Dropdown (Combobox) self.profile_dropdown = ttk.Combobox( - self.profile_frame, - textvariable=self.profile_var, - state="readonly", # Prevent typing custom values - width=35, - values=self.initial_profile_sections # Set initial list + self.profile_frame, textvariable=self.profile_var, + state="readonly", width=35, values=self.initial_profile_sections ) self.profile_dropdown.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=5) - # Bind selection change to load profile settings self.profile_dropdown.bind( "<>", - lambda event: self.load_profile_settings_callback( - self.profile_var.get() - ) + lambda e: self.load_profile_settings_callback(self.profile_var.get()) ) - # Trace variable for programmatic changes to also trigger load self.profile_var.trace_add( "write", - lambda *args: self.load_profile_settings_callback( - self.profile_var.get() - ) + lambda *a: self.load_profile_settings_callback(self.profile_var.get()) ) - # Save Settings Button self.save_settings_button = ttk.Button( - self.profile_frame, - text="Save Settings", - command=self.save_profile_callback # Use controller's save method + self.profile_frame, text="Save Settings", + command=self.save_profile_callback ) - # Place button next to the dropdown self.save_settings_button.grid(row=0, column=2, sticky=tk.W, padx=(5, 2), pady=5) self.create_tooltip(self.save_settings_button, - "Save current settings for selected profile.") + "Save settings for selected profile.") - # Add Profile Button self.add_profile_button = ttk.Button( - self.profile_frame, - text="Add", - width=5, # Fixed small width + self.profile_frame, text="Add", width=5, command=self.add_profile_callback ) self.add_profile_button.grid(row=0, column=3, sticky=tk.W, padx=(2, 0), pady=5) - # Remove Profile Button self.remove_profile_button = ttk.Button( - self.profile_frame, - text="Remove", - width=8, # Slightly wider than Add + self.profile_frame, text="Remove", width=8, command=self.remove_profile_callback ) self.remove_profile_button.grid(row=0, column=4, sticky=tk.W, padx=(2, 5), pady=5) - def _populate_setup_tab(self): - """Creates and places widgets for the Setup & Backup tab.""" - parent_frame = self.setup_tab_frame + def _create_notebook_with_tabs(self): + """Creates the main Notebook widget and its tabs.""" + self.notebook = ttk.Notebook(self, padding=(0, 5, 0, 0)) + self.notebook.pack(pady=5, padx=0, fill="both", expand=True) - # Create sub-frames within the tab for better organization - # Pack them vertically, expanding horizontally - repo_paths_frame = self._create_repo_paths_frame(parent_frame) + # Create frames for each tab's content + self.setup_tab_frame = ttk.Frame(self.notebook, padding=(10)) + self.commit_branch_tab_frame = ttk.Frame(self.notebook, padding=(10)) + self.tags_gitignore_tab_frame = ttk.Frame(self.notebook, padding=(10)) + + # Add frames as tabs + self.notebook.add(self.setup_tab_frame, text=' Setup & Backup ') + self.notebook.add(self.commit_branch_tab_frame, text=' Commit & Branches ') + self.notebook.add(self.tags_gitignore_tab_frame, text=' Tags & Gitignore ') + + # Populate each tab with its widgets + self._populate_setup_tab(self.setup_tab_frame) + self._populate_commit_branch_tab(self.commit_branch_tab_frame) + self._populate_tags_gitignore_tab(self.tags_gitignore_tab_frame) + + + def _populate_setup_tab(self, parent_tab_frame): + """Creates and places widgets for the Setup & Backup tab.""" + # Create and pack sub-frames within this tab + repo_paths_frame = self._create_repo_paths_frame(parent_tab_frame) repo_paths_frame.pack(pady=(0, 5), fill="x", expand=False) - backup_config_frame = self._create_backup_config_frame(parent_frame) + backup_config_frame = self._create_backup_config_frame(parent_tab_frame) backup_config_frame.pack(pady=5, fill="x", expand=False) @@ -602,12 +582,11 @@ class MainFrame(ttk.Frame): """Creates the sub-frame for repository paths and bundle names.""" frame = ttk.LabelFrame(parent, text="Repository & Bundle Paths", padding=(10, 5)) - # Define columns for layout consistency col_label = 0 col_entry = 1 col_button = 2 col_indicator = 3 - # Configure entry column (1) to expand horizontally + # Configure entry column to expand horizontally frame.columnconfigure(col_entry, weight=1) # Row 0: SVN Path @@ -616,126 +595,118 @@ class MainFrame(ttk.Frame): self.svn_path_entry = ttk.Entry(frame, width=60) self.svn_path_entry.grid(row=0, column=col_entry, sticky=tk.EW, padx=5, pady=3) # Bind events to trigger status updates - self.svn_path_entry.bind( - "", - lambda e: self.update_svn_status_callback(self.svn_path_entry.get()) - ) - self.svn_path_entry.bind( - "", - lambda e: self.update_svn_status_callback(self.svn_path_entry.get()) - ) + self.svn_path_entry.bind("", lambda e: self.update_svn_status_callback(self.svn_path_entry.get())) + self.svn_path_entry.bind("", lambda e: self.update_svn_status_callback(self.svn_path_entry.get())) + # Button uses local browse_folder method self.svn_path_browse_button = ttk.Button( - frame, text="Browse...", width=9, - command=lambda: self.browse_folder_callback(self.svn_path_entry) + frame, + text="Browse...", + width=9, + command=lambda w=self.svn_path_entry: self.browse_folder(w) ) - self.svn_path_browse_button.grid(row=0, column=col_button, sticky=tk.W, - padx=(0, 5), pady=3) - # Status Indicator (Green/Red dot) + self.svn_path_browse_button.grid(row=0, column=col_button, sticky=tk.W, padx=(0, 5), pady=3) + # Status Indicator self.svn_status_indicator = tk.Label( frame, text="", width=2, height=1, relief=tk.SUNKEN, background=self.RED, anchor=tk.CENTER ) - self.svn_status_indicator.grid(row=0, column=col_indicator, sticky=tk.E, - padx=(0, 5), pady=3) - self.create_tooltip(self.svn_status_indicator, - "Git repo status (Green=Ready, Red=Not Ready)") + self.svn_status_indicator.grid(row=0, column=col_indicator, sticky=tk.E, padx=(0, 5), pady=3) + self.create_tooltip(self.svn_status_indicator, "Git repo status (Green=Ready, Red=Not Ready)") # Row 1: USB/Bundle Target Path usb_label = ttk.Label(frame, text="Bundle Target Dir:") usb_label.grid(row=1, column=col_label, sticky=tk.W, padx=5, pady=3) self.usb_path_entry = ttk.Entry(frame, width=60) self.usb_path_entry.grid(row=1, column=col_entry, sticky=tk.EW, padx=5, pady=3) + # Button uses local browse_folder method self.usb_path_browse_button = ttk.Button( - frame, text="Browse...", width=9, - command=lambda: self.browse_folder_callback(self.usb_path_entry) + frame, + text="Browse...", + width=9, + command=lambda w=self.usb_path_entry: self.browse_folder(w) ) - self.usb_path_browse_button.grid(row=1, column=col_button, sticky=tk.W, - padx=(0, 5), pady=3) + self.usb_path_browse_button.grid(row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=3) # Row 2: Create Bundle Name create_label = ttk.Label(frame, text="Create Bundle Name:") create_label.grid(row=2, column=col_label, sticky=tk.W, padx=5, pady=3) self.bundle_name_entry = ttk.Entry(frame, width=60) - # Span entry across entry and button columns - self.bundle_name_entry.grid(row=2, column=col_entry, columnspan=2, - sticky=tk.EW, padx=5, pady=3) + self.bundle_name_entry.grid(row=2, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3) # Row 3: Fetch Bundle Name fetch_label = ttk.Label(frame, text="Fetch Bundle Name:") fetch_label.grid(row=3, column=col_label, sticky=tk.W, padx=5, pady=3) self.bundle_updated_name_entry = ttk.Entry(frame, width=60) - # Span entry across entry and button columns - self.bundle_updated_name_entry.grid(row=3, column=col_entry, columnspan=2, - sticky=tk.EW, padx=5, pady=3) + self.bundle_updated_name_entry.grid(row=3, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3) - return frame # Return the created frame + return frame def _create_backup_config_frame(self, parent): """Creates the sub-frame for backup configuration.""" - frame = ttk.LabelFrame(parent, text="Backup Configuration (ZIP)", - padding=(10, 5)) - # Define columns + frame = ttk.LabelFrame(parent, text="Backup Configuration (ZIP)", padding=(10, 5)) col_label = 0 col_entry = 1 col_button = 2 - # Configure entry column to expand - frame.columnconfigure(col_entry, weight=1) + frame.columnconfigure(col_entry, weight=1) # Entry expands # Row 0: Autobackup Checkbox self.autobackup_checkbox = ttk.Checkbutton( - frame, text="Automatic Backup before Create/Fetch", - variable=self.autobackup_var, command=self.toggle_backup_dir + frame, + text="Automatic Backup before Create/Fetch", + variable=self.autobackup_var, + command=self.toggle_backup_dir ) - # Span checkbox across all columns - self.autobackup_checkbox.grid(row=0, column=col_label, columnspan=3, - sticky=tk.W, padx=5, pady=(5, 0)) + self.autobackup_checkbox.grid(row=0, column=col_label, columnspan=3, sticky=tk.W, padx=5, pady=(5, 0)) - # Row 1: Backup Directory Entry and Browse Button + # Row 1: Backup Directory backup_dir_label = ttk.Label(frame, text="Backup Directory:") backup_dir_label.grid(row=1, column=col_label, sticky=tk.W, padx=5, pady=5) self.backup_dir_entry = ttk.Entry( - frame, textvariable=self.backup_dir_var, width=60, state=tk.DISABLED - ) - self.backup_dir_entry.grid(row=1, column=col_entry, sticky=tk.EW, padx=5, pady=5) - self.backup_dir_button = ttk.Button( - frame, text="Browse...", width=9, command=self.browse_backup_dir, + frame, + textvariable=self.backup_dir_var, + width=60, state=tk.DISABLED ) - self.backup_dir_button.grid(row=1, column=col_button, sticky=tk.W, - padx=(0, 5), pady=5) + self.backup_dir_entry.grid(row=1, column=col_entry, sticky=tk.EW, padx=5, pady=5) + # Button uses local browse_backup_dir method + self.backup_dir_button = ttk.Button( + frame, + text="Browse...", + width=9, + command=self.browse_backup_dir, # Call local method + state=tk.DISABLED + ) + self.backup_dir_button.grid(row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=5) - # Row 2: Exclude Extensions Entry + # Row 2: Exclude Extensions exclude_label = ttk.Label(frame, text="Exclude Extensions:") exclude_label.grid(row=2, column=col_label, sticky=tk.W, padx=5, pady=5) self.backup_exclude_entry = ttk.Entry( - frame, textvariable=self.backup_exclude_extensions_var, width=60 + frame, + textvariable=self.backup_exclude_extensions_var, + width=60 ) # Span entry across entry and button columns - self.backup_exclude_entry.grid(row=2, column=col_entry, columnspan=2, - sticky=tk.EW, padx=5, pady=5) - self.create_tooltip(self.backup_exclude_entry, - "Comma-separated extensions (e.g., .log,.tmp,.bak)") + self.backup_exclude_entry.grid(row=2, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=5) + self.create_tooltip(self.backup_exclude_entry, "Comma-separated (e.g., .log,.tmp)") - return frame # Return the created frame + return frame - def _populate_commit_branch_tab(self): + def _populate_commit_branch_tab(self, parent_tab_frame): """Creates and places widgets for the Commit & Branches tab.""" - parent_frame = self.commit_branch_tab_frame - # Configure grid columns for overall tab layout - parent_frame.columnconfigure(0, weight=1) # Column with listbox expands - parent_frame.rowconfigure(1, weight=1) # Row with listbox expands vertically + # Configure overall tab columns/rows for expansion + parent_tab_frame.columnconfigure(0, weight=1) # Listbox column expands + parent_tab_frame.rowconfigure(1, weight=1) # Branch subframe row expands - # --- Commit Section (Top) --- - commit_subframe = self._create_commit_management_frame(parent_frame) - commit_subframe.grid(row=0, column=0, columnspan=2, # Span both columns - sticky="ew", padx=0, pady=(0, 10)) + # Create Commit sub-frame (positioned at top) + commit_subframe = self._create_commit_management_frame(parent_tab_frame) + commit_subframe.grid(row=0, column=0, columnspan=2, sticky="ew", padx=0, pady=(0, 10)) - # --- Branch Section (Bottom) --- - branch_subframe = self._create_branch_management_frame(parent_frame) - branch_subframe.grid(row=1, column=0, columnspan=2, # Span both columns - sticky="nsew", padx=0, pady=0) + # Create Branch sub-frame (positioned below commit) + branch_subframe = self._create_branch_management_frame(parent_tab_frame) + branch_subframe.grid(row=1, column=0, columnspan=2, sticky="nsew", padx=0, pady=0) def _create_commit_management_frame(self, parent): @@ -744,87 +715,98 @@ class MainFrame(ttk.Frame): # Configure internal columns frame.columnconfigure(1, weight=1) # Entry expands - # Row 0: Autocommit Checkbox (for Create Bundle action) + # Row 0: Autocommit Checkbox self.autocommit_checkbox = ttk.Checkbutton( - frame, text="Autocommit before 'Create Bundle' (uses message below)", - variable=self.autocommit_var, state=tk.DISABLED + frame, + text="Autocommit before 'Create Bundle' (uses message below)", + variable=self.autocommit_var, + state=tk.DISABLED # State depends on repo readiness ) - self.autocommit_checkbox.grid(row=0, column=0, columnspan=3, - sticky="w", padx=5, pady=(5, 3)) - self.create_tooltip(self.autocommit_checkbox, - "If checked, commit changes using the message before Create Bundle.") + self.autocommit_checkbox.grid(row=0, column=0, columnspan=3, sticky="w", padx=5, pady=(5, 3)) + self.create_tooltip(self.autocommit_checkbox, "If checked, commit changes before Create Bundle.") # Row 1: Commit Message Entry + Manual Commit Button commit_msg_label = ttk.Label(frame, text="Commit Message:") commit_msg_label.grid(row=1, column=0, sticky="w", padx=5, pady=3) + self.commit_message_entry = ttk.Entry( - frame, textvariable=self.commit_message_var, width=50, state=tk.DISABLED + frame, + textvariable=self.commit_message_var, + width=50, # Adjust width as needed + state=tk.DISABLED # State depends on repo readiness ) self.commit_message_entry.grid(row=1, column=1, sticky="ew", padx=5, pady=3) - self.create_tooltip(self.commit_message_entry, - "Message for manual commit or autocommit.") + self.create_tooltip(self.commit_message_entry, "Message for manual commit or autocommit.") + # Manual Commit Button self.commit_button = ttk.Button( - frame, text="Commit Changes", width=15, - command=self.manual_commit_callback, state=tk.DISABLED + frame, + text="Commit Changes", + width=15, # Adjusted width + command=self.manual_commit_callback, # Connect to controller + state=tk.DISABLED # State depends on repo readiness ) self.commit_button.grid(row=1, column=2, sticky="w", padx=(5, 0), pady=3) - self.create_tooltip(self.commit_button, - "Manually commit staged changes with this message.") + self.create_tooltip(self.commit_button, "Manually commit staged changes with this message.") - return frame # Return the created frame + return frame def _create_branch_management_frame(self, parent): """Creates the sub-frame for branch operations.""" frame = ttk.LabelFrame(parent, text="Branches", padding=5) # Configure grid columns within this frame - frame.columnconfigure(0, weight=1) # Listbox column expands + frame.columnconfigure(1, weight=1) # Listbox column expands frame.rowconfigure(2, weight=1) # Listbox row expands # Row 0: Current Branch Display current_branch_label = ttk.Label(frame, text="Current Branch:") current_branch_label.grid(row=0, column=0, sticky="w", padx=5, pady=3) + self.current_branch_display = ttk.Label( - frame, textvariable=self.current_branch_var, - font=("Segoe UI", 9, "bold"), relief=tk.SUNKEN, padding=(3, 1) + frame, + textvariable=self.current_branch_var, # Use Tkinter variable + font=("Segoe UI", 9, "bold"), # Style for emphasis + relief=tk.SUNKEN, # Sunken appearance + padding=(3, 1) # Internal padding ) - # Span display across listbox and button columns? Or just listbox? - self.current_branch_display.grid(row=0, column=1, columnspan=2, # Span 2 - sticky="ew", padx=5, pady=3) - self.create_tooltip(self.current_branch_display, - "The currently active branch or state.") + # Span display across listbox and button columns? Or just listbox? Let's span 2 + self.current_branch_display.grid(row=0, column=1, columnspan=2, sticky="ew", padx=5, pady=3) + self.create_tooltip(self.current_branch_display, "The currently active branch or state.") # Row 1: Listbox Label branch_list_label = ttk.Label(frame, text="Local Branches:") - branch_list_label.grid(row=1, column=0, columnspan=3, # Span all columns - sticky="w", padx=5, pady=(10, 0)) + # Span label across all columns below it + branch_list_label.grid(row=1, column=0, columnspan=4, sticky="w", padx=5, pady=(10, 0)) # Row 2: Listbox + Scrollbar Frame (Spans first 3 columns) branch_list_frame = ttk.Frame(frame) - branch_list_frame.grid(row=2, column=0, columnspan=3, sticky="nsew", - padx=5, pady=(0, 5)) + branch_list_frame.grid(row=2, column=0, columnspan=3, sticky="nsew", padx=5, pady=(0, 5)) branch_list_frame.rowconfigure(0, weight=1) branch_list_frame.columnconfigure(0, weight=1) self.branch_listbox = tk.Listbox( - branch_list_frame, height=5, exportselection=False, - selectmode=tk.SINGLE, font=("Consolas", 9) + branch_list_frame, + height=5, # Initial height + exportselection=False, + selectmode=tk.SINGLE, + font=("Consolas", 9) # Monospaced font for potential alignment ) self.branch_listbox.grid(row=0, column=0, sticky="nsew") + branch_scrollbar = ttk.Scrollbar( - branch_list_frame, orient=tk.VERTICAL, + branch_list_frame, + orient=tk.VERTICAL, command=self.branch_listbox.yview ) branch_scrollbar.grid(row=0, column=1, sticky="ns") self.branch_listbox.config(yscrollcommand=branch_scrollbar.set) - self.create_tooltip(self.branch_listbox, - "Select a branch for actions (Switch, Delete).") + self.create_tooltip(self.branch_listbox, "Select a branch for actions (Switch, Delete).") # Row 2, Column 3: Vertical Button Frame for Branch Actions branch_button_frame = ttk.Frame(frame) - branch_button_frame.grid(row=2, column=3, sticky="ns", # North-South align - padx=(10, 5), pady=(0, 5)) # Add left padding + # Place it in the 4th column (index 3), aligned with listbox row + branch_button_frame.grid(row=2, column=3, sticky="ns", padx=(10, 5), pady=(0, 5)) button_width_branch = 18 # Consistent width @@ -847,7 +829,7 @@ class MainFrame(ttk.Frame): command=self.switch_branch_callback, state=tk.DISABLED ) self.switch_branch_button.pack(side=tk.TOP, fill=tk.X, pady=3) - self.create_tooltip(self.switch_branch_button, "Checkout selected branch.") + self.create_tooltip(self.switch_branch_button, "Checkout the selected branch.") self.delete_branch_button = ttk.Button( branch_button_frame, text="Delete Selected", width=button_width_branch, @@ -859,110 +841,141 @@ class MainFrame(ttk.Frame): return frame # Return the created frame - def _populate_tags_gitignore_tab(self): + def _populate_tags_gitignore_tab(self, parent_tab_frame): """Creates and places widgets for the Tags & Gitignore tab.""" - parent_frame = self.tags_gitignore_tab_frame - # Configure grid - parent_frame.columnconfigure(0, weight=1) # Listbox expands - parent_frame.rowconfigure(0, weight=1) # Listbox expands vertically + # Configure grid: listbox expands, button column fixed width + parent_tab_frame.columnconfigure(0, weight=1) + parent_tab_frame.rowconfigure(0, weight=1) # Listbox row expands vertically - # --- Tag Listing Area --- - tag_list_frame = ttk.LabelFrame(parent_frame, text="Tags", padding=5) - tag_list_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) - tag_list_frame.rowconfigure(0, weight=1) - tag_list_frame.columnconfigure(0, weight=1) + # --- Tag Management Section (Left part of the tab) --- + tag_list_frame = self._create_tag_management_frame(parent_tab_frame) + # Span both rows (tag list and potential future rows) + tag_list_frame.grid(row=0, column=0, rowspan=2, + sticky="nsew", padx=(0, 5), pady=5) + # --- Tag/Gitignore Actions Section (Right part, vertical buttons) --- + tag_action_frame = self._create_tag_action_frame(parent_tab_frame) + # Span both rows to align vertically + tag_action_frame.grid(row=0, column=1, rowspan=2, + sticky="ns", padx=(5, 0), pady=5) + + + def _create_tag_management_frame(self, parent): + """Creates the sub-frame containing the tag listbox.""" + frame = ttk.LabelFrame(parent, text="Tags", padding=5) + # Configure internal grid for expansion + frame.rowconfigure(0, weight=1) + frame.columnconfigure(0, weight=1) + + # Listbox for tags self.tag_listbox = tk.Listbox( - tag_list_frame, height=8, exportselection=False, - selectmode=tk.SINGLE, font=("Consolas", 9) + frame, + height=8, # More visible rows for tags + exportselection=False, + selectmode=tk.SINGLE, + font=("Consolas", 9) # Monospaced font for alignment ) self.tag_listbox.grid(row=0, column=0, sticky="nsew") + + # Scrollbar for tag listbox tag_scrollbar = ttk.Scrollbar( - tag_list_frame, orient=tk.VERTICAL, command=self.tag_listbox.yview + frame, + orient=tk.VERTICAL, + command=self.tag_listbox.yview ) tag_scrollbar.grid(row=0, column=1, sticky="ns") self.tag_listbox.config(yscrollcommand=tag_scrollbar.set) self.create_tooltip(self.tag_listbox, "Tags (newest first) with messages. Select for actions.") + return frame - # --- Tag/Gitignore Action Buttons Area --- (Vertical column) - tag_button_frame = ttk.Frame(parent_frame) - tag_button_frame.grid(row=0, column=1, rowspan=2, # Span rows potentially - sticky="ns", padx=(0, 5), pady=5) - button_width_tag = 18 # Consistent width + def _create_tag_action_frame(self, parent): + """Creates the vertical frame for Tag and Gitignore action buttons.""" + frame = ttk.Frame(parent) # Simple frame container + # Consistent button width for this column + button_width = 18 + # Refresh Tags Button self.refresh_tags_button = ttk.Button( - tag_button_frame, text="Refresh Tags", width=button_width_tag, + frame, text="Refresh Tags", width=button_width, command=self.refresh_tags_callback, state=tk.DISABLED ) self.refresh_tags_button.pack(side=tk.TOP, fill=tk.X, pady=(0, 3)) self.create_tooltip(self.refresh_tags_button, "Reload tag list.") + # Create Tag Button self.create_tag_button = ttk.Button( - tag_button_frame, text="Create Tag...", width=button_width_tag, + frame, text="Create Tag...", width=button_width, command=self.create_tag_callback, state=tk.DISABLED ) self.create_tag_button.pack(side=tk.TOP, fill=tk.X, pady=3) self.create_tooltip(self.create_tag_button, "Commit changes (if message provided) & create tag.") + # Checkout Tag Button self.checkout_tag_button = ttk.Button( - tag_button_frame, text="Checkout Selected Tag", width=button_width_tag, + frame, text="Checkout Selected Tag", width=button_width, command=self.checkout_tag_callback, state=tk.DISABLED ) self.checkout_tag_button.pack(side=tk.TOP, fill=tk.X, pady=3) self.create_tooltip(self.checkout_tag_button, "Switch to selected tag (Detached HEAD).") - # --- ADDED: Delete Tag Button --- + # Delete Tag Button self.delete_tag_button = ttk.Button( - tag_button_frame, text="Delete Selected Tag", width=button_width_tag, - command=self.delete_tag_callback, state=tk.DISABLED + frame, text="Delete Selected Tag", width=button_width, + command=self.delete_tag_callback, state=tk.DISABLED # Connect callback ) self.delete_tag_button.pack(side=tk.TOP, fill=tk.X, pady=3) - self.create_tooltip(self.delete_tag_button, - "Delete the selected tag locally.") + self.create_tooltip(self.delete_tag_button, "Delete selected tag locally.") - # Edit .gitignore button (also in this column) + # Edit .gitignore Button self.edit_gitignore_button = ttk.Button( - tag_button_frame, text="Edit .gitignore", width=button_width_tag, + frame, text="Edit .gitignore", width=button_width, command=self.open_gitignore_editor_callback, state=tk.DISABLED ) self.edit_gitignore_button.pack(side=tk.TOP, fill=tk.X, pady=(3, 0)) self.create_tooltip(self.edit_gitignore_button, "Open editor for the .gitignore file.") + return frame + def _create_function_frame(self): """Creates the frame holding the Core Action buttons (below tabs).""" self.function_frame = ttk.LabelFrame( self, text="Core Actions", padding=(10, 10) ) + # Pack below notebook, but above log area self.function_frame.pack(pady=(5, 5), fill="x", anchor=tk.N) # Sub-frame to center the buttons horizontally button_subframe = ttk.Frame(self.function_frame) - button_subframe.pack() # Default pack behavior centers horizontally + button_subframe.pack() # Default pack centers content + # Prepare SVN button self.prepare_svn_button = ttk.Button( button_subframe, text="Prepare SVN Repo", command=self.prepare_svn_for_git_callback ) self.prepare_svn_button.pack(side=tk.LEFT, padx=(0,5), pady=5) + # Create Bundle button self.create_bundle_button = ttk.Button( button_subframe, text="Create Bundle", command=self.create_git_bundle_callback ) self.create_bundle_button.pack(side=tk.LEFT, padx=5, pady=5) + # Fetch Bundle button self.fetch_bundle_button = ttk.Button( button_subframe, text="Fetch from Bundle", command=self.fetch_from_git_bundle_callback ) self.fetch_bundle_button.pack(side=tk.LEFT, padx=5, pady=5) + # Manual Backup Button self.manual_backup_button = ttk.Button( button_subframe, text="Backup Now (ZIP)", command=self.manual_backup_callback @@ -991,11 +1004,40 @@ class MainFrame(ttk.Frame): except ImportError: DEFAULT_PROFILE = "default" # Fallback + # Set dropdown value based on available profiles if DEFAULT_PROFILE in self.initial_profile_sections: self.profile_var.set(DEFAULT_PROFILE) elif self.initial_profile_sections: + # Select first available profile if default not found self.profile_var.set(self.initial_profile_sections[0]) - # else: profile_var remains empty + # else: profile_var remains empty if no profiles exist + + + # --- ADDED: browse_folder method (moved from GitUtilityApp) --- + def browse_folder(self, entry_widget): + """ + Opens a folder selection dialog and updates the specified Entry widget. + """ + # Suggest initial directory + current_path = entry_widget.get() + initial_dir = current_path if os.path.isdir(current_path) else \ + os.path.expanduser("~") + + # Show dialog using tkinter's filedialog + directory = filedialog.askdirectory( + initialdir=initial_dir, + title="Select Directory", + parent=self.master # Make dialog modal + ) + + if directory: # If a directory was selected + # Update the entry widget + entry_widget.delete(0, tk.END) + entry_widget.insert(0, directory) + # Trigger controller's status update if SVN path changed + if entry_widget == self.svn_path_entry: + self.update_svn_status_callback(directory) + # else: User cancelled # --- GUI Update Methods --- @@ -1009,13 +1051,10 @@ class MainFrame(ttk.Frame): def browse_backup_dir(self): - """Opens a directory selection dialog for backup directory.""" - initial = self.backup_dir_var.get() or DEFAULT_BACKUP_DIR - dirname = filedialog.askdirectory(initialdir=initial, - title="Select Backup Dir", - parent=self.master) - if dirname: - self.backup_dir_var.set(dirname) + """Opens folder dialog specifically for the backup directory.""" + # Use the local browse_folder method for consistency + if hasattr(self, 'backup_dir_entry'): + self.browse_folder(self.backup_dir_entry) def update_svn_indicator(self, is_prepared): @@ -1024,9 +1063,11 @@ class MainFrame(ttk.Frame): state = tk.DISABLED if is_prepared else tk.NORMAL tip = "Repo Prepared" if is_prepared else "Repo Not Prepared" + # Update indicator's background and tooltip if hasattr(self, 'svn_status_indicator'): self.svn_status_indicator.config(background=color) self.update_tooltip(self.svn_status_indicator, tip) + # Update Prepare button's state if hasattr(self, 'prepare_svn_button'): self.prepare_svn_button.config(state=state) @@ -1035,61 +1076,77 @@ class MainFrame(ttk.Frame): """Updates the profile combobox list.""" if hasattr(self, 'profile_dropdown'): current = self.profile_var.get() + # Set new values for the dropdown self.profile_dropdown['values'] = sections # Maintain selection logic if sections: if current in sections: + # Setting same value might not trigger trace, but is correct state self.profile_var.set(current) elif "default" in sections: - self.profile_var.set("default") + self.profile_var.set("default") # Triggers load else: - self.profile_var.set(sections[0]) + self.profile_var.set(sections[0]) # Triggers load else: - self.profile_var.set("") + self.profile_var.set("") # Triggers load with empty def update_tag_list(self, tags_with_subjects): """Clears and repopulates tag listbox with name and subject.""" if not hasattr(self, 'tag_listbox'): - logging.error("Tag listbox missing for update.") + # Log error if listbox doesn't exist when called + logging.error("Cannot update tag list: Listbox widget not found.") return try: - self.tag_listbox.delete(0, tk.END) + self.tag_listbox.delete(0, tk.END) # Clear list if tags_with_subjects: - # Reset color if needed + # Reset text color if it was previously greyed out try: - if self.tag_listbox.cget("fg") == "grey": + current_fg = self.tag_listbox.cget("fg") + if current_fg == "grey": + # Use standard system text color name self.tag_listbox.config(fg='SystemWindowText') except tk.TclError: - pass # Ignore color errors - # Insert items + # Fallback if SystemWindowText is unknown + try: + self.tag_listbox.config(fg='black') + except tk.TclError: + pass # Ignore color setting errors if all fails + + # Insert formatted tag strings for name, subject in tags_with_subjects: - display = f"{name}\t({subject})" # Tab separation + # Use tab separation for basic alignment + display = f"{name}\t({subject})" self.tag_listbox.insert(tk.END, display) else: - # Show placeholder + # Show placeholder text if no tags self.tag_listbox.insert(tk.END, "(No tags found)") try: - self.tag_listbox.config(fg="grey") + self.tag_listbox.config(fg="grey") # Dim placeholder text except tk.TclError: - pass # Ignore color errors + pass # Ignore color setting errors + except tk.TclError as e: - logging.error(f"TclError updating tags: {e}") + logging.error(f"TclError updating tag listbox: {e}") except Exception as e: - logging.error(f"Error updating tags: {e}", exc_info=True) + logging.error(f"Error updating tag listbox: {e}", exc_info=True) def get_selected_tag(self): """Returns the name only of the selected tag.""" + tag_name = None if hasattr(self, 'tag_listbox'): indices = self.tag_listbox.curselection() + # Check if there is a selection (curselection returns tuple) if indices: - item = self.tag_listbox.get(indices[0]) + selected_index = indices[0] # Get the index + item = self.tag_listbox.get(selected_index) # Get text at index + # Ignore placeholder text if item != "(No tags found)": - # Get text before the first tab + # Extract name (text before the first tab) tag_name = item.split('\t', 1)[0] - return tag_name.strip() - return None # No selection or invalid item + tag_name = tag_name.strip() # Remove any extra spaces + return tag_name # Return name or None def update_branch_list(self, branches): @@ -1099,7 +1156,7 @@ class MainFrame(ttk.Frame): return try: current = self.current_branch_var.get() # Get displayed current branch - self.branch_listbox.delete(0, tk.END) + self.branch_listbox.delete(0, tk.END) # Clear list if branches: # Reset color if needed try: @@ -1110,9 +1167,9 @@ class MainFrame(ttk.Frame): for branch in branches: is_current = (branch == current) # Add '*' prefix for current branch display - display = f"* {branch}" if is_current else f" {branch}" - self.branch_listbox.insert(tk.END, display) - # Highlight current branch in the list + display_name = f"* {branch}" if is_current else f" {branch}" + self.branch_listbox.insert(tk.END, display_name) + # Apply styling for current branch (if needed) if is_current: self.branch_listbox.itemconfig( tk.END, {'fg': 'blue', 'selectbackground': 'lightblue'} @@ -1120,7 +1177,8 @@ class MainFrame(ttk.Frame): else: # Show placeholder if no branches self.branch_listbox.insert(tk.END, "(No local branches?)") - try: self.branch_listbox.config(fg="grey") + try: + self.branch_listbox.config(fg="grey") # Dim placeholder except tk.TclError: pass except tk.TclError as e: logging.error(f"TclError updating branches: {e}") @@ -1130,13 +1188,14 @@ class MainFrame(ttk.Frame): def get_selected_branch(self): """Returns the name only of the selected branch.""" + branch_name = None if hasattr(self, 'branch_listbox'): indices = self.branch_listbox.curselection() if indices: item = self.branch_listbox.get(indices[0]) # Remove potential '*' prefix and leading/trailing whitespace - return item.lstrip("* ").strip() - return None # No selection + branch_name = item.lstrip("* ").strip() + return branch_name # Return name or None def set_current_branch_display(self, branch_name): @@ -1174,13 +1233,12 @@ class MainFrame(ttk.Frame): def create_tooltip(self, widget, text): """Creates a tooltip for a given widget.""" tooltip = Tooltip(widget, text) - # Use add='+' to avoid overwriting other bindings + # Use add='+' to ensure other bindings are not overwritten widget.bind("", lambda e, tt=tooltip: tt.showtip(), add='+') widget.bind("", lambda e, tt=tooltip: tt.hidetip(), add='+') # Hide tooltip also when clicking the widget widget.bind("", lambda e, tt=tooltip: tt.hidetip(), add='+') - def update_tooltip(self, widget, text): """Updates the text of an existing tooltip (by re-creating it).""" # Simple approach: Remove old bindings and create new tooltip