diff --git a/GitUtility.py b/GitUtility.py index c1abdec..e8fdf58 100644 --- a/GitUtility.py +++ b/GitUtility.py @@ -1,29 +1,30 @@ -# GitUtility.py +# GitTool.py import os -import sys -# import shutil # Not needed anymore +import shutil import datetime import tkinter as tk from tkinter import messagebox import logging -# import zipfile # Not needed anymore +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 -from gui import MainFrame, GitignoreEditorWindow, CreateTagDialog, CreateBranchDialog -# Import Handler classes -from backup_handler import BackupHandler -from profile_handler import ProfileHandler -from action_handler import ActionHandler +from gui import ( + MainFrame, + GitignoreEditorWindow, + CreateTagDialog, + CreateBranchDialog, +) # Include necessary dialogs class GitSvnSyncApp: """ - Main application class: Coordinates GUI, configuration, and actions. - Delegates logic to specific handler classes. + Main application class for the Git SVN Sync Tool. + Coordinates the GUI (now tabbed), configuration, and Git commands. """ def __init__(self, master): @@ -37,1206 +38,1211 @@ class GitSvnSyncApp: master.title("Git SVN Sync Tool") master.protocol("WM_DELETE_WINDOW", self.on_closing) - # --- Early Logger Setup --- + # Basic logging setup first logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s" + level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s" ) self.logger = logging.getLogger("GitSvnSyncApp") - # --- Initialize Core Components --- + # Initialize Configuration Manager try: self.config_manager = ConfigManager(self.logger) - self.git_commands = GitCommands(self.logger) - # Initialize Handlers - self.profile_handler = ProfileHandler(self.logger, self.config_manager) - self.backup_handler = BackupHandler(self.logger) - self.action_handler = ActionHandler( - self.logger, self.git_commands, self.backup_handler + except Exception as e: + self.logger.critical(f"Failed init ConfigManager: {e}", exc_info=True) + self.show_fatal_error(f"Config Error:\n{e}") + master.destroy() + return + + # Initialize MainFrame (GUI) with all necessary callbacks + try: + self.main_frame = MainFrame( + master, + # Profile Load + load_profile_settings_cb=self.load_profile_settings, + # Browse + browse_folder_cb=self.browse_folder, + # Status Update (triggers UI state changes) + update_svn_status_cb=self.update_svn_status_indicator, + # Actions from Repo Tab + 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, + open_gitignore_editor_cb=self.open_gitignore_editor, + # Action from Backup Tab + manual_backup_cb=self.manual_backup, + # Config Manager related + 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, + save_profile_cb=self.save_profile_settings, + # Action from Commit Tab + commit_changes_cb=self.commit_changes, + # Actions from Tags Tab + refresh_tags_cb=self.refresh_tag_list, + create_tag_cb=self.create_tag, + checkout_tag_cb=self.checkout_tag, + # Actions from Branch Tab + refresh_branches_cb=self.refresh_branch_list, + checkout_branch_cb=self.checkout_branch, + create_branch_cb=self.create_branch, + # Action from History Tab + refresh_history_cb=self.refresh_commit_history, ) except Exception as e: - self.logger.critical(f"Failed component init: {e}", exc_info=True) - self.show_fatal_error(f"Initialization Error:\n{e}\nApp cannot start.") - master.destroy() - return + self.logger.critical(f"Failed init MainFrame: {e}", exc_info=True) + self.show_fatal_error(f"GUI Error:\n{e}") + master.destroy() + return - # --- Create GUI Main Frame --- - try: - self.main_frame = MainFrame( - master, - load_profile_settings_cb=self.load_profile_settings, - # browse_folder_cb REMOVED - Handled within MainFrame now - update_svn_status_cb=self.update_svn_status_indicator, - # Core Action Callbacks - prepare_svn_for_git_cb=self.ui_prepare_svn, - create_git_bundle_cb=self.ui_create_bundle, - fetch_from_git_bundle_cb=self.ui_fetch_bundle, - manual_backup_cb=self.ui_manual_backup, - # Profile/Settings Callbacks - add_profile_cb=self.ui_add_profile, - remove_profile_cb=self.ui_remove_profile, - save_profile_cb=self.ui_save_settings, - # Commit/Gitignore Callbacks - manual_commit_cb=self.ui_manual_commit, - open_gitignore_editor_cb=self.open_gitignore_editor, - # Tag Callbacks - refresh_tags_cb=self.refresh_tag_list, - create_tag_cb=self.ui_create_tag, - checkout_tag_cb=self.ui_checkout_tag, - delete_tag_cb=self.ui_delete_tag, # Pass new callback - # Branch Callbacks - refresh_branches_cb=self.refresh_branch_list, - create_branch_cb=self.ui_create_branch, - switch_branch_cb=self.ui_switch_branch, - delete_branch_cb=self.ui_delete_branch, - # Pass instances/data if needed by GUI - config_manager_instance=self.config_manager, - profile_sections_list=self.profile_handler.get_profile_list() - ) - except Exception as e: - self.logger.critical(f"Failed init MainFrame: {e}", exc_info=True) - self.show_fatal_error(f"GUI Error:\n{e}\nApp cannot start.") - master.destroy() - return - - # --- Enhanced Logger Setup --- + # Finalize Logger Setup using the GUI text widget self.logger = setup_logger(self.main_frame.log_text) - # Ensure all components use the final logger - self.config_manager.logger = self.logger - self.git_commands.logger = self.logger - self.profile_handler.logger = self.logger - self.backup_handler.logger = self.logger - self.action_handler.logger = self.logger + self.config_manager.logger = self.logger # Ensure manager uses final logger + + # Initialize Git Commands Handler + self.git_commands = GitCommands(self.logger) # --- Initial Application State --- self.logger.info("Application initializing...") initial_profile = self.main_frame.profile_var.get() if initial_profile: - self.logger.debug(f"Initial profile: '{initial_profile}'. Loading...") - # Load settings (called automatically by trace on profile_var) + self.logger.debug(f"Initial profile: '{initial_profile}'. Loading...") + # Settings load triggers refresh of tags, branches, history via trace else: - self.logger.warning("No profile selected on startup.") - self._clear_and_disable_fields() # Set initial disabled state + self.logger.warning("No profile selected on startup.") + self._clear_and_disable_fields() # Disable everything initially self.logger.info("Application started successfully.") - def on_closing(self): - """Handles window close event.""" + """Handles the window close event.""" self.logger.info("Application closing.") + # Add cleanup or checks for unsaved work here if needed self.master.destroy() - - # --- Helper methods to get/validate paths from GUI --- - def _get_and_validate_svn_path(self, operation_name="Operation"): - """Retrieves and validates the SVN path from the GUI.""" - if not hasattr(self, 'main_frame'): - self.logger.error(f"{operation_name}: GUI component unavailable.") - return None - if not hasattr(self.main_frame, 'svn_path_entry'): - self.logger.error(f"{operation_name}: SVN path widget missing.") - if hasattr(self, 'main_frame'): - self.main_frame.show_error("Internal Error", "SVN Path widget missing.") - return None - - svn_path_str = self.main_frame.svn_path_entry.get() - svn_path_str = svn_path_str.strip() - if not svn_path_str: - self.logger.error(f"{operation_name}: SVN Path is empty.") - self.main_frame.show_error("Input Error", "SVN Path cannot be empty.") - return None - - abs_path = os.path.abspath(svn_path_str) - if not os.path.isdir(abs_path): - self.logger.error(f"{operation_name}: Invalid directory path: {abs_path}") - self.main_frame.show_error("Input Error", f"Invalid SVN path:\n{abs_path}") - return None - - self.logger.debug(f"{operation_name}: Validated SVN path: {abs_path}") - return abs_path - - - def _get_and_validate_usb_path(self, operation_name="Operation"): - """Retrieves and validates the USB/Bundle Target path from the GUI.""" - if not hasattr(self, 'main_frame'): - self.logger.error(f"{operation_name}: GUI component unavailable.") - return None - if not hasattr(self.main_frame, 'usb_path_entry'): - self.logger.error(f"{operation_name}: USB path widget missing.") - if hasattr(self, 'main_frame'): - self.main_frame.show_error("Internal Error", "USB Path widget missing.") - return None - - usb_path_str = self.main_frame.usb_path_entry.get() - usb_path_str = usb_path_str.strip() - if not usb_path_str: - self.logger.error(f"{operation_name}: Bundle Target Dir path empty.") - self.main_frame.show_error("Input Error", "Bundle Target Dir empty.") - return None - - abs_path = os.path.abspath(usb_path_str) - if not os.path.isdir(abs_path): - self.logger.error(f"{operation_name}: Invalid Bundle Target: {abs_path}") - self.main_frame.show_error("Input Error", f"Invalid Bundle Target:\n{abs_path}") - return None - - self.logger.debug(f"{operation_name}: Validated Bundle Target path: {abs_path}") - return abs_path - - # --- ADDED: Helper to parse exclusions directly here --- - def _parse_exclusions(self, profile_name): - """ - Parses exclusion string from config for the given profile. - Needed here because ActionHandler might not have direct ConfigManager access, - and exclusions are needed for backup steps within actions. - - Args: - profile_name (str): The name of the profile. - - Returns: - tuple: (set of excluded extensions (lowercase, starting with '.'), - set of excluded base directory names (lowercase)) - Raises: - ValueError: If exclusion string parsing fails. - """ - try: - # Get exclusion string from config - exclude_str = self.config_manager.get_profile_option( - profile_name, "backup_exclude_extensions", fallback="" - ) - excluded_extensions = set() - # Define standard directories to always exclude (lowercase for comparison) - excluded_dirs_base = {".git", ".svn"} - - if exclude_str: - raw_extensions = exclude_str.split(',') - for ext in raw_extensions: - clean_ext = ext.strip().lower() - if not clean_ext: - continue # Skip empty parts - # Ensure extension starts with a dot - if not clean_ext.startswith('.'): - clean_ext = '.' + clean_ext - excluded_extensions.add(clean_ext) - - self.logger.debug( - f"Parsed Exclusions '{profile_name}' - " - f"Ext: {excluded_extensions}, Dirs: {excluded_dirs_base}" - ) - return excluded_extensions, excluded_dirs_base - except Exception as e: - self.logger.error(f"Error parsing exclusions for '{profile_name}': {e}", - exc_info=True) - # Raise a specific error to indicate parsing failure - raise ValueError(f"Could not parse backup exclusions: {e}") from e - - - # --- Profile Handling Wrappers --- + # --- Profile Management --- def load_profile_settings(self, profile_name): - """Loads profile settings into GUI when profile selection changes.""" - self.logger.info(f"UI Request: Load profile '{profile_name}'") + """Loads settings for the selected profile into the GUI.""" + self.logger.info(f"Loading settings for profile: '{profile_name}'") if not profile_name: + self.logger.warning("No profile selected to load.") + self._clear_and_disable_fields() # Reset UI if no profile + return + if profile_name not in self.config_manager.get_profile_sections(): + self.logger.error(f"Profile '{profile_name}' not found.") + self.main_frame.show_error("Error", f"Profile '{profile_name}' not found.") self._clear_and_disable_fields() return - # Delegate loading to ProfileHandler - profile_data = self.profile_handler.load_profile_data(profile_name) - if not profile_data: - # Handler logs error, show message and clear UI - self.main_frame.show_error("Load Error", f"Could not load '{profile_name}'.") - self._clear_and_disable_fields() - return + # Load data from config manager + cm = self.config_manager + 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 = cm.get_profile_option(profile_name, "bundle_name_updated", "") + autocommit = cm.get_profile_option(profile_name, "autocommit", "False") + commit_msg = cm.get_profile_option(profile_name, "commit_message", "") + autobackup = 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 fields with loaded data if frame exists - if hasattr(self, 'main_frame'): - mf = self.main_frame # Alias - # Update all widgets based on loaded data + # Update GUI widgets + if hasattr(self, "main_frame"): + mf = self.main_frame + # Repo Tab mf.svn_path_entry.delete(0, tk.END) - mf.svn_path_entry.insert(0, profile_data.get("svn_working_copy_path", "")) + mf.svn_path_entry.insert(0, svn_path) mf.usb_path_entry.delete(0, tk.END) - mf.usb_path_entry.insert(0, profile_data.get("usb_drive_path", "")) + mf.usb_path_entry.insert(0, usb_path) mf.bundle_name_entry.delete(0, tk.END) - mf.bundle_name_entry.insert(0, profile_data.get("bundle_name", "")) + mf.bundle_name_entry.insert(0, bundle_name) mf.bundle_updated_name_entry.delete(0, tk.END) - mf.bundle_updated_name_entry.insert(0, profile_data.get("bundle_name_updated", "")) - mf.autocommit_var.set(profile_data.get("autocommit", False)) # Bool - mf.commit_message_var.set(profile_data.get("commit_message", "")) - mf.autobackup_var.set(profile_data.get("autobackup", False)) # Bool - mf.backup_dir_var.set(profile_data.get("backup_dir", DEFAULT_BACKUP_DIR)) - mf.backup_exclude_extensions_var.set( - profile_data.get("backup_exclude_extensions", ".log,.tmp") + mf.bundle_updated_name_entry.insert(0, bundle_upd) + # Backup Tab + mf.autobackup_var.set(autobackup.lower() == "true") + mf.backup_dir_var.set(backup_dir) + mf.backup_exclude_extensions_var.set(excludes) + mf.toggle_backup_dir() + # Commit Tab + mf.autocommit_var.set(autocommit.lower() == "true") + mf.clear_commit_message() # Clear first + if hasattr(mf, "commit_message_text"): + if mf.commit_message_text.cget("state") == tk.DISABLED: + mf.commit_message_text.config(state=tk.NORMAL) + mf.commit_message_text.insert("1.0", commit_msg) # Insert loaded msg + # State will be set by update_svn_status_indicator + + # Update Status & Enable/Disable Buttons + self.update_svn_status_indicator(svn_path) + self._enable_function_buttons() + + # Refresh dynamic lists if repo is ready + repo_ready = ( + svn_path + and os.path.isdir(svn_path) + and os.path.exists(os.path.join(svn_path, ".git")) ) - mf.toggle_backup_dir() # Update backup dir entry state - - # Update status indicators and enable/disable buttons based on path - svn_path = profile_data.get("svn_working_copy_path", "") - self.update_svn_status_indicator(svn_path) # Updates state widgets - self._enable_function_buttons() # Enable general action buttons - - # Refresh tag and branch lists if repo ready - repo_ready = (svn_path and os.path.isdir(svn_path) and - os.path.exists(os.path.join(svn_path, ".git"))) if repo_ready: - self.refresh_tag_list() - self.refresh_branch_list() - self.update_current_branch_display() # Update display label + self.refresh_tag_list() + self.refresh_branch_list() + self.refresh_commit_history() else: - # Clear lists if repo not ready - mf.update_tag_list([]) - mf.update_branch_list([]) - mf.set_current_branch_display("") # Reset display label + # Clear lists if repo not ready + mf.update_tag_list([]) + mf.update_branch_list([], None) + mf.update_history_display([]) + mf.update_history_branch_filter([]) # Clear history filter too - self.logger.info(f"Settings loaded successfully for '{profile_name}'.") + self.logger.info(f"Settings loaded for '{profile_name}'.") else: - self.logger.error("Cannot load settings: Main frame missing.") + self.logger.error("Cannot load settings: Main frame missing.") - - def ui_save_settings(self): - """Callback for the 'Save Settings' button.""" - self.logger.debug("UI Request: Save Settings button clicked.") + def save_profile_settings(self): + """Saves current GUI values to the selected profile.""" profile = self.main_frame.profile_var.get() if not profile: - self.main_frame.show_error("Save Error", "No profile selected.") - return False # Indicate failure to caller if needed - - # Gather data from GUI - current_data = self._get_data_from_gui() - if current_data is None: # Check if reading GUI failed - self.main_frame.show_error("Internal Error", "Could not read GUI data.") - return False - - # Delegate saving to ProfileHandler - success = self.profile_handler.save_profile_data(profile, current_data) - - if success: - self.main_frame.show_info("Saved", f"Settings saved for '{profile}'.") + self.logger.warning("Cannot save: No profile selected.") + return False + self.logger.info(f"Saving settings for profile: '{profile}'") + try: + cm = self.config_manager + mf = self.main_frame + # Repo Tab + 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() + ) + # Commit Tab + cm.set_profile_option(profile, "autocommit", str(mf.autocommit_var.get())) + commit_msg = mf.get_commit_message() # Get from ScrolledText + cm.set_profile_option(profile, "commit_message", commit_msg) + # Backup Tab + 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(), + ) + # Save to file + cm.save_config() + self.logger.info(f"Settings saved for '{profile}'.") return True - else: - # Error message likely shown by handler/save method - # self.main_frame.show_error("Save Error", f"Failed save settings.") + except Exception as e: + self.logger.error(f"Error saving '{profile}': {e}", exc_info=True) + self.main_frame.show_error("Save Error", f"Failed save:\n{e}") return False - - def _get_data_from_gui(self): - """Helper to gather current settings from GUI widgets into a dict.""" - if not hasattr(self, 'main_frame'): - self.logger.error("Cannot get GUI data: Main frame missing.") - return None # Indicate failure - - mf = self.main_frame - # Read values from all relevant widgets/variables - data = {} - data["svn_working_copy_path"] = mf.svn_path_entry.get() - data["usb_drive_path"] = mf.usb_path_entry.get() - data["bundle_name"] = mf.bundle_name_entry.get() - data["bundle_name_updated"] = mf.bundle_updated_name_entry.get() - data["autocommit"] = mf.autocommit_var.get() # Gets boolean - data["commit_message"] = mf.commit_message_var.get() - data["autobackup"] = mf.autobackup_var.get() # Gets boolean - data["backup_dir"] = mf.backup_dir_var.get() - data["backup_exclude_extensions"] = mf.backup_exclude_extensions_var.get() - return data - - - def ui_add_profile(self): - """Callback for the 'Add Profile' button.""" - self.logger.debug("UI Request: Add Profile.") - new_name = self.main_frame.ask_new_profile_name() - if not new_name: - self.logger.info("Add profile cancelled."); return - - new_name = new_name.strip() - if not new_name: - self.main_frame.show_error("Error", "Profile name empty."); return - - # Delegate adding logic - success = self.profile_handler.add_new_profile(new_name) - if success: - sections = self.profile_handler.get_profile_list() + def add_profile(self): + """Handles adding a new profile.""" + self.logger.debug("'Add Profile' clicked.") + name = self.main_frame.ask_new_profile_name() + if not name: + self.logger.info("Add cancelled.") + return + name = name.strip() + if not name: + self.logger.warning("Empty name.") + self.main_frame.show_error("Error", "Name empty.") + return + if name in self.config_manager.get_profile_sections(): + self.logger.warning(f"Exists: '{name}'") + self.main_frame.show_error("Error", f"'{name}' exists.") + return + self.logger.info(f"Adding profile: '{name}'") + try: + defaults = self.config_manager._get_expected_keys_with_defaults() + defaults["bundle_name"] = f"{name}_repo.bundle" + defaults["bundle_name_updated"] = f"{name}_update.bundle" + defaults["svn_working_copy_path"] = "" + defaults["usb_drive_path"] = "" + for k, v in defaults.items(): + self.config_manager.set_profile_option(name, k, v) + self.config_manager.save_config() + sections = self.config_manager.get_profile_sections() self.main_frame.update_profile_dropdown(sections) - self.main_frame.profile_var.set(new_name) # Triggers load - self.main_frame.show_info("Profile Added", f"Profile '{new_name}' created.") - else: - self.main_frame.show_error("Error", f"Could not add profile '{new_name}'.") + self.main_frame.profile_var.set(name) # Select new profile + self.logger.info(f"Profile '{name}' added.") + except Exception as e: + self.logger.error(f"Error adding '{name}': {e}", exc_info=True) + self.main_frame.show_error("Error", f"Failed add:\n{e}") - - def ui_remove_profile(self): - """Callback for the 'Remove Profile' button.""" - self.logger.debug("UI Request: Remove Profile.") + def remove_profile(self): + """Handles removing the selected profile.""" + self.logger.debug("'Remove Profile' clicked.") profile = self.main_frame.profile_var.get() if not profile: - self.main_frame.show_error("Error", "No profile selected."); return + self.logger.warning("No profile.") + self.main_frame.show_error("Error", "No profile.") + return if profile == DEFAULT_PROFILE: - self.main_frame.show_error("Error", f"Cannot remove '{DEFAULT_PROFILE}'."); return - - # Confirm with user - msg = f"Remove profile '{profile}'?" - if self.main_frame.ask_yes_no("Remove Profile", msg): - # Delegate removal - success = self.profile_handler.remove_existing_profile(profile) - if success: - sections = self.profile_handler.get_profile_list() - self.main_frame.update_profile_dropdown(sections) # Triggers load - self.main_frame.show_info("Removed", f"Profile '{profile}' removed.") - else: - self.main_frame.show_error("Error", f"Failed to remove '{profile}'.") + self.logger.warning("Cannot remove default.") + self.main_frame.show_error("Error", f"Cannot remove '{DEFAULT_PROFILE}'.") + return + if self.main_frame.ask_yes_no("Remove Profile", f"Remove '{profile}'?"): + self.logger.info(f"Removing: '{profile}'") + try: + if self.config_manager.remove_profile_section(profile): + self.config_manager.save_config() + self.logger.info("Removed.") + sections = self.config_manager.get_profile_sections() + self.main_frame.update_profile_dropdown( + sections + ) # Update list, selection changes automatically + else: + self.main_frame.show_error("Error", "Failed remove.") + except Exception as e: + self.logger.error(f"Error removing: {e}", exc_info=True) + self.main_frame.show_error("Error", f"Error removing:\n{e}") else: self.logger.info("Removal cancelled.") - - # --- GUI Interaction Wrappers --- + # --- GUI Interaction --- def browse_folder(self, entry_widget): """Opens folder dialog to update an entry widget.""" self.logger.debug("Browse folder requested.") current = entry_widget.get() initial = current if os.path.isdir(current) else os.path.expanduser("~") - directory = filedialog.askdirectory(initialdir=initial, - title="Select Directory", - parent=self.master) + directory = filedialog.askdirectory( + initialdir=initial, title="Select Directory", parent=self.master + ) if directory: self.logger.debug(f"Selected: {directory}") entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) - # Trigger status update if SVN path changed if entry_widget == self.main_frame.svn_path_entry: self.update_svn_status_indicator(directory) else: self.logger.debug("Browse cancelled.") - def update_svn_status_indicator(self, svn_path): - """Checks repo status and updates all dependent GUI widget states.""" + """Checks repo status and updates states of dependent GUI widgets.""" is_valid = bool(svn_path and os.path.isdir(svn_path)) is_ready = is_valid and os.path.exists(os.path.join(svn_path, ".git")) - self.logger.debug(f"Updating status indicators for '{svn_path}'. " - f"Valid:{is_valid}, Ready:{is_ready}") + self.logger.debug( + f"Updating status for '{svn_path}'. Valid:{is_valid}, Ready:{is_ready}" + ) - if hasattr(self, 'main_frame'): - mf = self.main_frame # Alias - # Update indicator & Prepare button via GUI method + if hasattr(self, "main_frame"): + mf = self.main_frame + # Update indicator & Prepare button (Repo Tab) mf.update_svn_indicator(is_ready) - # Determine states for other dependent widgets + # Determine states based on validity and readiness gitignore_state = tk.NORMAL if is_valid else tk.DISABLED repo_ready_state = tk.NORMAL if is_ready else tk.DISABLED - # Apply states to relevant widgets if they exist - widget_states = { - 'edit_gitignore_button': gitignore_state, - 'commit_message_entry': repo_ready_state, - 'autocommit_checkbox': repo_ready_state, - 'commit_button': repo_ready_state, - 'refresh_branches_button': repo_ready_state, - 'create_branch_button': repo_ready_state, - 'switch_branch_button': repo_ready_state, - 'delete_branch_button': repo_ready_state, - 'refresh_tags_button': repo_ready_state, - 'create_tag_button': repo_ready_state, - 'checkout_tag_button': repo_ready_state, - 'delete_tag_button': repo_ready_state, - } - for widget_name, state in widget_states.items(): - widget = getattr(mf, widget_name, None) - if widget: - # Ensure config method is called safely - try: - widget.config(state=state) - except tk.TclError as e: - self.logger.warning(f"TclError configuring {widget_name}: {e}") - - # Update current branch display if repo not ready - if not is_ready: - mf.set_current_branch_display("") - + # Update states: Repo Tab + if hasattr(mf, "edit_gitignore_button"): + mf.edit_gitignore_button.config(state=gitignore_state) + if hasattr(mf, "create_bundle_button"): + mf.create_bundle_button.config(state=repo_ready_state) + if hasattr(mf, "fetch_bundle_button"): + mf.fetch_bundle_button.config(state=repo_ready_state) + # Update states: Backup Tab + if hasattr(mf, "manual_backup_button"): + mf.manual_backup_button.config(state=repo_ready_state) + # Update states: Commit Tab + if hasattr(mf, "autocommit_checkbox"): + mf.autocommit_checkbox.config(state=repo_ready_state) + if hasattr(mf, "commit_message_text"): + mf.commit_message_text.config(state=repo_ready_state) + if hasattr(mf, "commit_button"): + mf.commit_button.config(state=repo_ready_state) + # Update states: Tags Tab + if hasattr(mf, "refresh_tags_button"): + mf.refresh_tags_button.config(state=repo_ready_state) + if hasattr(mf, "create_tag_button"): + mf.create_tag_button.config(state=repo_ready_state) + if hasattr(mf, "checkout_tag_button"): + mf.checkout_tag_button.config(state=repo_ready_state) + # Update states: Branches Tab + if hasattr(mf, "refresh_branches_button"): + mf.refresh_branches_button.config(state=repo_ready_state) + if hasattr(mf, "create_branch_button"): + mf.create_branch_button.config(state=repo_ready_state) + if hasattr(mf, "checkout_branch_button"): + mf.checkout_branch_button.config(state=repo_ready_state) + # Update states: History Tab + if hasattr(mf, "history_branch_filter_combo"): + mf.history_branch_filter_combo.config(state=repo_ready_state) + if hasattr(mf, "refresh_history_button"): + mf.refresh_history_button.config(state=repo_ready_state) def open_gitignore_editor(self): - """Opens the modal editor window for .gitignore.""" - self.logger.info("--- Action Triggered: Edit .gitignore ---") + """Opens the editor window for .gitignore.""" + self.logger.info("--- Action: Edit .gitignore ---") svn_path = self._get_and_validate_svn_path("Edit .gitignore") - if not svn_path: return - + if not svn_path: + return gitignore_path = os.path.join(svn_path, ".gitignore") - self.logger.debug(f"Target .gitignore path: {gitignore_path}") + self.logger.debug(f"Target: {gitignore_path}") try: - # Create and run the modal editor window editor = GitignoreEditorWindow(self.master, gitignore_path, self.logger) - # Execution blocks here until editor is closed - self.logger.debug("Gitignore editor finished.") + self.logger.debug("Editor opened.") except Exception as e: - self.logger.exception(f"Error opening .gitignore editor: {e}") - self.main_frame.show_error("Editor Error", - f"Could not open editor:\n{e}") + self.logger.exception(f"Editor error: {e}") + self.main_frame.show_error("Error", f"Editor error:\n{e}") + + def _get_and_validate_svn_path(self, operation_name="Operation"): + """ + Retrieves the SVN path from the GUI entry, validates its existence + as a directory, shows errors in GUI, and logs issues. + + Args: + operation_name (str): Name of the operation requesting the path + (for logging/error messages). + + Returns: + str: The validated absolute path if valid, None otherwise. + """ + # Check if main_frame and the specific widget exist first + 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.") + # Avoid showing messagebox if GUI isn't fully ready + # self.main_frame.show_error("GUI Error", "Cannot access path field.") + return None + + svn_path_str = self.main_frame.svn_path_entry.get() + svn_path_str = svn_path_str.strip() + + if not svn_path_str: + self.logger.error(f"{operation_name}: SVN Working Copy Path is empty.") + self.main_frame.show_error( + "Input Error", + "Please specify the SVN Working Copy Path." + ) + return None + + # Convert to absolute path and check if it's a directory + abs_path = os.path.abspath(svn_path_str) + if not os.path.isdir(abs_path): + self.logger.error( + f"{operation_name}: Specified SVN path is not a valid " + f"directory: {abs_path}" + ) + self.main_frame.show_error( + "Input Error", + f"Invalid SVN path (not a directory):\n{abs_path}" + ) + return None + + # If all checks pass + self.logger.debug(f"{operation_name}: Using validated SVN path: {abs_path}") + return abs_path - # --- Core Action Wrappers (GUI Callbacks) --- + def _get_and_validate_usb_path(self, operation_name="Operation"): + """ + Retrieves the USB/Bundle Target path from the GUI entry, validates + its existence as a directory, shows errors, and logs issues. - def ui_prepare_svn(self): - """Callback for 'Prepare SVN Repo' button.""" - self.logger.info("--- Action Triggered: Prepare SVN Repo ---") - svn_path = self._get_and_validate_svn_path("Prepare SVN") - if not svn_path: return + Args: + operation_name (str): Name of the operation requesting the path. - # Save settings before action - # Use ui_save_settings which returns True/False - if not self.ui_save_settings(): - self.logger.warning("Prepare SVN: Failed to save settings first.") - # Ask user if they want to continue? - # if not self.main_frame.ask_yes_no("Warning", "Could not save settings.\nContinue anyway?"): - # return + Returns: + str: The validated absolute path if valid, None otherwise. + """ + # Check if main_frame and the specific widget exist first + 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 - # Delegate execution to ActionHandler - try: - self.action_handler.execute_prepare_repo(svn_path) - self.main_frame.show_info("Success", "Repository prepared.") - # Update GUI state after successful preparation + 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", + "Please specify the Bundle Target Directory." + ) + return None + + # Convert to absolute path and check if it's a directory + abs_path = os.path.abspath(usb_path_str) + if not os.path.isdir(abs_path): + self.logger.error( + f"{operation_name}: Specified Bundle Target path is not a " + f"valid directory: {abs_path}" + ) + self.main_frame.show_error( + "Input Error", + f"Invalid Bundle Target path (not a directory):\n{abs_path}" + ) + return None + + # If all checks pass + self.logger.debug(f"{operation_name}: Using validated Bundle Target path: {abs_path}") + return abs_path + + # --- Core Repo/Bundle/Backup Actions (now triggered from specific tabs) --- + + def prepare_svn_for_git(self): + """Handles the 'Prepare SVN Repo' action (Repo Tab).""" + self.logger.info("--- Action: Prepare Repo ---") + svn_path = self._get_and_validate_svn_path("Prepare") + if not svn_path: + return + if not self.save_profile_settings(): + self.logger.warning("Prepare: Save failed.") + if os.path.exists(os.path.join(svn_path, ".git")): + self.logger.info("Already prepared.") + self.main_frame.show_info("Info", "Already prepared.") + self.update_svn_status_indicator(svn_path) + return + self.logger.info(f"Preparing: {svn_path}") + try: + self.git_commands.prepare_svn_for_git(svn_path) + self.logger.info("Prepared.") + self.main_frame.show_info("Success", "Repo prepared.") + self.update_svn_status_indicator(svn_path) + except (GitCommandError, ValueError) as e: + self.logger.error(f"Prepare error: {e}") + self.main_frame.show_error("Error", f"Failed:\n{e}") self.update_svn_status_indicator(svn_path) - except ValueError as e: # Catch specific "already prepared" error - self.main_frame.show_info("Info", str(e)) - self.update_svn_status_indicator(svn_path) # Ensure UI reflects state - except (GitCommandError, IOError) as e: - self.main_frame.show_error("Error", f"Failed prepare:\n{e}") - self.update_svn_status_indicator(svn_path) # Update state after failure except Exception as e: self.logger.exception(f"Unexpected prepare error: {e}") - self.main_frame.show_error("Error", f"Unexpected error:\n{e}") + self.main_frame.show_error("Error", f"Unexpected:\n{e}") self.update_svn_status_indicator(svn_path) - - def ui_create_bundle(self): - """Callback for 'Create Bundle' button.""" - self.logger.info("--- Action Triggered: Create Git Bundle ---") + def create_git_bundle(self): + """Handles the 'Create Bundle' action (Repo Tab).""" + self.logger.info("--- Action: Create Bundle ---") profile = self.main_frame.profile_var.get() if not profile: - self.main_frame.show_error("Error", "No profile selected.") + self.logger.error("Bundle: No profile.") + self.main_frame.show_error("Error", "No profile.") return - - # Validate inputs - svn_path = self._get_and_validate_svn_path("Create Bundle") - if not svn_path: return - usb_path = self._get_and_validate_usb_path("Create Bundle") - if not usb_path: return - bundle_name = self.main_frame.bundle_name_entry.get().strip() - if not bundle_name: - self.main_frame.show_error("Input Error", "Bundle name empty.") + svn_path = self._get_and_validate_svn_path("Bundle") + if not svn_path: return - # Ensure .bundle extension - if not bundle_name.lower().endswith(".bundle"): - bundle_name += ".bundle" - mf = self.main_frame # Alias - mf.bundle_name_entry.delete(0, tk.END) - mf.bundle_name_entry.insert(0, bundle_name) - bundle_full_path = os.path.join(usb_path, bundle_name) - - # Get settings needed by action handler - settings = self._get_data_from_gui() - if not settings: return - backup_needed = settings.get("autobackup", False) - commit_needed = settings.get("autocommit", False) - commit_msg = settings.get("commit_message", "") - backup_dir = settings.get("backup_dir", "") + usb_path = self._get_and_validate_usb_path("Bundle") + if not usb_path: + return + name = self.main_frame.bundle_name_entry.get().strip() + if not name: + self.logger.error("Bundle: Name empty.") + self.main_frame.show_error("Error", "Bundle name empty.") + return + if not name.lower().endswith(".bundle"): + self.logger.warning(f"Adding .bundle") + name += ".bundle" + mf = self.main_frame + mf.bundle_name_entry.delete(0, tk.END) + mf.bundle_name_entry.insert(0, name) + full_path = os.path.join(usb_path, name) + self.logger.debug(f"Target: {full_path}") + if not self.save_profile_settings(): + self.logger.warning("Bundle: Save failed.") + # Backup Step + if self.main_frame.autobackup_var.get(): + self.logger.info("Autobackup...") + if not self.create_backup(svn_path, profile): + self.logger.error("Aborted: backup fail.") + return + # Autocommit Step (using checkbox and message from Commit Tab) + if self.main_frame.autocommit_var.get(): + self.logger.info("Autocommit before bundle...") + try: + if self.git_commands.git_status_has_changes(svn_path): + self.logger.info("Changes detected for autocommit...") + msg = ( + self.main_frame.get_commit_message() + or f"Autocommit '{profile}' before bundle" + ) + self.logger.debug(f"Autocommit msg: '{msg}'") + if self.git_commands.git_commit(svn_path, msg): + self.logger.info("Autocommit ok.") + else: + self.logger.info("No changes for autocommit.") + except (GitCommandError, ValueError) as e: + self.logger.error(f"Autocommit error: {e}") + self.main_frame.show_error("Error", f"Autocommit Failed:\n{e}") + return + except Exception as e: + self.logger.exception(f"Unexpected autocommit: {e}") + self.main_frame.show_error("Error", f"Unexpected:\n{e}") + return + # Create Bundle Step + self.logger.info(f"Creating bundle: {full_path}") try: - # Parse exclusions needed for backup step - excluded_ext, excluded_dir = self._parse_exclusions(profile) - except ValueError as e: - self.main_frame.show_error("Config Error", str(e)) - return - - # Save settings before action - if not self.ui_save_settings(): - self.logger.warning("Create Bundle: Could not save settings.") - # Ask user? - - # Delegate execution to ActionHandler - try: - created_path = self.action_handler.execute_create_bundle( - svn_path, bundle_full_path, profile, backup_needed, backup_dir, - commit_needed, commit_msg, excluded_ext, excluded_dir - ) - # Show feedback based on result - if created_path: - self.main_frame.show_info("Success", f"Bundle created:\n{created_path}") + self.git_commands.create_git_bundle(svn_path, full_path) + if os.path.exists(full_path) and os.path.getsize(full_path) > 0: + self.logger.info("Bundle created.") + self.main_frame.show_info("Success", f"Bundle created:\n{full_path}") else: - # Non-fatal issue (e.g., empty bundle) - self.main_frame.show_warning("Info", "Bundle empty or not created.") + self.logger.warning("Bundle empty/not created.") + self.main_frame.show_warning("Info", "Bundle empty/not created.") + if os.path.exists(full_path): + try: + os.remove(full_path) + self.logger.info("Removed empty.") + except OSError as e: + self.logger.warning(f"Cannot remove empty: {e}") + except (GitCommandError, ValueError) as e: + self.logger.error(f"Bundle error: {e}") + self.main_frame.show_error("Error", f"Failed:\n{e}") except Exception as e: - # Handle errors raised by action_handler - self.logger.error(f"Bundle process error: {e}", exc_info=True) - self.main_frame.show_error("Error", f"Failed create bundle:\n{e}") + self.logger.exception(f"Unexpected bundle error: {e}") + self.main_frame.show_error("Error", f"Unexpected:\n{e}") - - def ui_fetch_bundle(self): - """Callback for 'Fetch Bundle' button.""" - self.logger.info("--- Action Triggered: Fetch from Git Bundle ---") + def fetch_from_git_bundle(self): + """Handles the 'Fetch from Bundle' action (Repo Tab).""" + self.logger.info("--- Action: Fetch Bundle ---") profile = self.main_frame.profile_var.get() if not profile: - self.main_frame.show_error("Error", "No profile selected.") + self.logger.error("Fetch: No profile.") + self.main_frame.show_error("Error", "No profile.") return - - # Validate inputs - svn_path = self._get_and_validate_svn_path("Fetch Bundle") - if not svn_path: return - usb_path = self._get_and_validate_usb_path("Fetch Bundle") - if not usb_path: return - bundle_name = self.main_frame.bundle_updated_name_entry.get().strip() - if not bundle_name: - self.main_frame.show_error("Input Error", "Fetch bundle name empty.") + svn_path = self._get_and_validate_svn_path("Fetch") + if not svn_path: return - bundle_full_path = os.path.join(usb_path, bundle_name) - if not os.path.isfile(bundle_full_path): - self.main_frame.show_error("Error", f"Bundle not found:\n{bundle_full_path}") + usb_path = self._get_and_validate_usb_path("Fetch") + if not usb_path: return - - # Get settings needed by action handler - settings = self._get_data_from_gui() - if not settings: return - backup_needed = settings.get("autobackup", False) - backup_dir = settings.get("backup_dir", "") + name = self.main_frame.bundle_updated_name_entry.get().strip() + if not name: + self.logger.error("Fetch: Name empty.") + self.main_frame.show_error("Error", "Fetch name empty.") + return + full_path = os.path.join(usb_path, name) + self.logger.debug(f"Source: {full_path}") + if not os.path.isfile(full_path): + self.logger.error(f"Fetch: Not found: {full_path}") + self.main_frame.show_error("Error", f"Not found:\n{full_path}") + return + if not self.save_profile_settings(): + self.logger.warning("Fetch: Could not save.") + if self.main_frame.autobackup_var.get(): + self.logger.info("Autobackup...") + if not self.create_backup(svn_path, profile): + self.logger.error("Aborted: backup fail.") + return + self.logger.info(f"Fetching into '{svn_path}' from: {full_path}") try: - excluded_ext, excluded_dir = self._parse_exclusions(profile) - except ValueError as e: - self.main_frame.show_error("Config Error", str(e)) - return - - # Save settings before action - if not self.ui_save_settings(): - self.logger.warning("Fetch Bundle: Could not save settings.") - # Ask user? - - # Delegate execution to ActionHandler - try: - self.action_handler.execute_fetch_bundle( - svn_path, bundle_full_path, profile, backup_needed, backup_dir, - excluded_ext, excluded_dir - ) - # Show generic success, conflicts handled by error message below - self.main_frame.show_info("Fetch Complete", - f"Fetch complete.\nCheck logs for status.") - # Refresh state after fetch completes successfully - self.refresh_branch_list() - self.update_current_branch_display() - self.refresh_tag_list() + self.git_commands.fetch_from_git_bundle(svn_path, full_path) + self.logger.info("Fetch/merge completed.") + self.main_frame.show_info("Fetch Complete", "Fetch complete.\nCheck logs.") except GitCommandError as e: - # Handle specific Git errors like merge conflicts - self.logger.error(f"Fetch/merge error: {e}", exc_info=False) + self.logger.error(f"Fetch/merge error: {e}") if "merge conflict" in str(e).lower(): - self.main_frame.show_error( - "Merge Conflict", - f"Conflict occurred.\nResolve manually in:\n{svn_path}\nThen commit." - ) + self.main_frame.show_error( + "Merge Conflict", + f"Conflict.\nResolve in:\n{svn_path}\nThen commit.", + ) else: - self.main_frame.show_error("Error", f"Fetch/Merge Failed:\n{e}") + self.main_frame.show_error("Error", f"Failed:\n{e}") + except ValueError as e: + self.logger.error(f"Validation error fetch: {e}") + self.main_frame.show_error("Error", f"Invalid:\n{e}") except Exception as e: - # Handle other errors (backup, unexpected) - self.logger.error(f"Fetch process error: {e}", exc_info=True) - self.main_frame.show_error("Error", f"Fetch failed:\n{e}") + self.logger.exception(f"Unexpected fetch error: {e}") + self.main_frame.show_error("Error", f"Unexpected:\n{e}") + # --- Backup Logic (from Backup Tab) --- + def _parse_exclusions(self, profile_name): + """Parses exclusion string from config.""" + exclude_str = self.config_manager.get_profile_option( + profile_name, "backup_exclude_extensions", "" + ) + extensions = set() + dirs = {".git", ".svn"} + if exclude_str: + for ext in exclude_str.split(","): + clean = ext.strip().lower() + if clean: + extensions.add("." + clean if not clean.startswith(".") else clean) + self.logger.debug( + f"Exclusions '{profile_name}' - Ext:{extensions}, Dirs:{dirs}" + ) + return extensions, dirs - def ui_manual_backup(self): - """Callback for 'Backup Now' button.""" - self.logger.info("--- Action Triggered: Manual Backup ---") - profile = self.main_frame.profile_var.get() - if not profile: - self.main_frame.show_error("Error", "No profile selected.") - return - - # Validate paths - svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})") - if not svn_path: return - backup_dir = self.main_frame.backup_dir_var.get().strip() - if not backup_dir: - self.main_frame.show_error("Backup Error", "Backup directory empty.") - return - - # Check/create backup dir - if not os.path.isdir(backup_dir): - create_q = f"Create directory:\n{backup_dir}?" - if self.main_frame.ask_yes_no("Create Directory?", create_q): - try: - os.makedirs(backup_dir, exist_ok=True) - except OSError as e: - self.main_frame.show_error("Error", f"Cannot create dir:\n{e}") - return # Stop if cannot create dir - else: - self.logger.info("User cancelled backup dir creation.") - return # Stop if user cancels creation - - # Parse exclusions needed for backup handler + def create_backup(self, source_path, profile): + """Creates a timestamped ZIP backup.""" + self.logger.info(f"Creating ZIP backup for '{profile}' from '{source_path}'") + base_dir = self.main_frame.backup_dir_var.get().strip() + if not base_dir: + self.logger.error("Backup Fail: Dir empty.") + self.main_frame.show_error("Error", "Backup dir empty.") + return False + if not os.path.isdir(base_dir): + self.logger.info(f"Creating backup dir: {base_dir}") + try: + os.makedirs(base_dir, exist_ok=True) + except OSError as e: + self.logger.error(f"Cannot create dir: {e}") + self.main_frame.show_error("Error", f"Cannot create dir:\n{e}") + return False try: excluded_ext, excluded_dir = self._parse_exclusions(profile) - except ValueError as e: - self.main_frame.show_error("Config Error", str(e)) - return - - # Save settings first (important for exclusions and backup dir) - if not self.ui_save_settings(): - confirm_q = "Could not save settings.\nContinue backup anyway?" - if not self.main_frame.ask_yes_no("Warning", confirm_q): - self.logger.warning("Manual backup aborted by user.") - return - - # Delegate backup creation to BackupHandler - try: - backup_path = self.backup_handler.create_zip_backup( - svn_path, backup_dir, profile, excluded_ext, excluded_dir - ) - # Show success message if path is returned - if backup_path: - self.main_frame.show_info( - "Backup Complete", f"Backup created:\n{backup_path}" - ) - # else: BackupHandler logs issues, maybe return specific status? except Exception as e: - # Handle any exception raised by backup handler - self.logger.error(f"Manual backup failed: {e}", exc_info=True) - self.main_frame.show_error("Backup Error", f"Failed:\n{e}") + self.logger.error(f"Parse error: {e}") + self.main_frame.show_error("Error", "Cannot parse exclusions.") + return False + now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + safe_prof = ( + "".join(c for c in profile if c.isalnum() or c in "_-").rstrip() + or "profile" + ) + filename = f"{now}_backup_{safe_prof}.zip" + full_path = os.path.join(base_dir, filename) + self.logger.info(f"Target ZIP: {full_path}") + added, excl_f, excl_d = 0, 0, 0 + zip_f = None + try: + zip_f = zipfile.ZipFile(full_path, "w", zipfile.ZIP_DEFLATED, True) + for root, dirs, files in os.walk(source_path, topdown=True): + orig_dirs = list(dirs) + dirs[:] = [d for d in dirs if d.lower() not in excluded_dir] + excl_now = set(orig_dirs) - set(dirs) + if excl_now: + excl_d += len(excl_now) + for d in excl_now: + self.logger.debug(f"Excl Dir: {os.path.join(root, d)}") + for file in files: + _, ext = os.path.splitext(file) + ext_low = ext.lower() + if file.lower() in excluded_dir or ext_low in excluded_ext: + self.logger.debug(f"Excl File: {os.path.join(root, file)}") + excl_f += 1 + continue + file_path = os.path.join(root, file) + arc_name = os.path.relpath(file_path, source_path) + try: + zip_f.write(file_path, arc_name) + added += 1 + if added % 500 == 0: + self.logger.debug(f"Added {added}...") + except Exception as we: + self.logger.error( + f"Write error '{file_path}': {we}", exc_info=True + ) + self.logger.info( + f"ZIP finished: {full_path} (Add:{added}, ExF:{excl_f}, ExD:{excl_d})" + ) + return True + except OSError as e: + self.logger.error(f"OS error ZIP: {e}") + self.main_frame.show_error("Error", f"OS Error:\n{e}") + return False + except zipfile.BadZipFile as e: + self.logger.error(f"ZIP format error: {e}") + self.main_frame.show_error("Error", f"ZIP format:\n{e}") + return False + except Exception as e: + self.logger.exception(f"Unexpected ZIP error: {e}") + self.main_frame.show_error("Error", f"Unexpected ZIP:\n{e}") + return False + finally: + if zip_f: + zip_f.close() + self.logger.debug("ZIP closed.") + if os.path.exists(full_path) and added == 0: + self.logger.warning(f"Empty ZIP: {full_path}") + try: + os.remove(full_path) + self.logger.info("Removed empty ZIP.") + except OSError as e: + self.logger.error(f"Failed remove empty ZIP: {e}") + def manual_backup(self): + """Handles the 'Backup Now' button click (Backup Tab).""" + self.logger.info("--- Action: Manual Backup ---") + profile = self.main_frame.profile_var.get() + if not profile: + self.logger.warning("Backup: No profile.") + self.main_frame.show_error("Error", "No profile.") + return + svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})") + if not svn_path: + return + self.logger.info("Saving settings before backup...") + if not self.save_profile_settings(): + self.logger.error("Backup: Could not save settings.") + if not self.main_frame.ask_yes_no( + "Error", "Could not save.\nContinue anyway?" + ): + self.logger.warning("Backup aborted.") + return + self.logger.info(f"Starting manual backup for '{profile}'...") + success = self.create_backup(svn_path, profile) # Calls ZIP backup + if success: + self.main_frame.show_info("Success", "Manual ZIP backup completed.") + else: + self.logger.error( + f"Manual backup failed for '{profile}'." + ) # Error shown by create_backup - def ui_manual_commit(self): - """Callback for the 'Commit Changes' button.""" - self.logger.info("--- Action Triggered: Manual Commit ---") - svn_path = self._get_and_validate_svn_path("Manual Commit") - if not svn_path: return - - # Get commit message from the GUI entry - commit_msg = self.main_frame.commit_message_var.get().strip() - if not commit_msg: - self.main_frame.show_error("Commit Error", "Commit message empty.") + # --- Commit Action (from Commit Tab) --- + def commit_changes(self): + """Handles the 'Commit Changes' button click (Commit Tab).""" + self.logger.info("--- Action: Commit Changes ---") + svn_path = self._get_and_validate_svn_path("Commit Changes") + if not svn_path: return - # Save settings first? Optional, but saves the message if user typed it. - if not self.ui_save_settings(): - self.logger.warning("Manual Commit: Could not save settings.") - # Ask user? - - # Delegate commit execution to ActionHandler - try: - commit_made = self.action_handler.execute_manual_commit( - svn_path, commit_msg + # Get commit message from the ScrolledText widget + commit_msg = self.main_frame.get_commit_message() + if not commit_msg: + self.logger.warning("Commit failed: Message is empty.") + self.main_frame.show_error( + "Commit Error", "Commit message cannot be empty." ) + return + + # Save settings before commit (optional, ensures consistency) + if not self.save_profile_settings(): + self.logger.warning("Commit Changes: Could not save settings first.") + + # Perform the commit + try: + commit_made = self.git_commands.git_commit(svn_path, commit_msg) if commit_made: - self.main_frame.show_info("Success", "Changes committed.") - # Clear message field after successful commit? Optional. - # self.main_frame.commit_message_var.set("") + self.logger.info("Commit successful.") + self.main_frame.show_info("Success", "Changes committed.") + # Clear the commit message box after successful commit + self.main_frame.clear_commit_message() + # Refresh history view after commit + self.refresh_commit_history() else: - # git_commit already logged "nothing to commit" - self.main_frame.show_info("Info", "No changes to commit.") + # git_commit logs "nothing to commit" + self.main_frame.show_info("No Changes", "No changes to commit.") except (GitCommandError, ValueError) as e: - self.logger.error(f"Manual commit failed: {e}") + self.logger.error(f"Commit failed: {e}") self.main_frame.show_error("Commit Error", f"Failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected commit error: {e}") - self.main_frame.show_error("Error", f"Unexpected commit error:\n{e}") + self.main_frame.show_error("Error", f"Unexpected:\n{e}") - - # --- Tag Management Callbacks --- + # --- Tag Management (from Tags Tab) --- def refresh_tag_list(self): - """Refreshes tag list in GUI.""" - self.logger.info("--- Action: Refresh Tag List ---") + """Fetches tags with subjects and updates GUI listbox.""" + self.logger.info("--- Action: Refresh Tags ---") svn_path = self._get_and_validate_svn_path("Refresh Tags") - # Repo must be ready to list tags - repo_ready = svn_path and os.path.exists(os.path.join(svn_path, ".git")) - if not repo_ready: - if hasattr(self, 'main_frame'): - self.main_frame.update_tag_list([]) # Clear list - return - - # Fetch tags and update GUI + if not svn_path or not os.path.exists(os.path.join(svn_path, ".git")): + if hasattr(self, "main_frame"): + self.main_frame.update_tag_list([]) + return # Silently return if repo not ready try: - # list_tags returns list of tuples (name, subject) - tags_data = self.git_commands.list_tags(svn_path) - if hasattr(self, 'main_frame'): + tags_data = self.git_commands.list_tags(svn_path) # Returns tuples + if hasattr(self, "main_frame"): self.main_frame.update_tag_list(tags_data) self.logger.info(f"Tag list updated ({len(tags_data)} tags).") except Exception as e: self.logger.error(f"Failed refresh tags: {e}", exc_info=True) self.main_frame.show_error("Error", f"Could not refresh tags:\n{e}") - if hasattr(self, 'main_frame'): + if hasattr(self, "main_frame"): self.main_frame.update_tag_list([]) - - def ui_create_tag(self): - """Callback for 'Create Tag' button.""" - self.logger.info("--- Action Triggered: Create Tag ---") + 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 svn_path: + return + profile = self.main_frame.profile_var.get() # Needed for commit msg? if not profile: - self.main_frame.show_error("Error", "No profile selected.") + self.logger.error("Tag Error: No profile") + self.main_frame.show_error("Error", "No profile.") return - # Get commit message from GUI (needed by action handler for pre-commit) - commit_msg = self.main_frame.commit_message_var.get().strip() + if not self.save_profile_settings(): + self.logger.warning("Tag: Could not save.") - # Save settings before action (saves commit message) - if not self.ui_save_settings(): - self.logger.warning("Create Tag: Could not save settings first.") - # Ask user? - - # --- Open Dialog to get Tag Name and Tag Message --- - self.logger.debug("Opening create tag dialog...") - dialog = CreateTagDialog(self.master) # Parent is the main Tk window - tag_info = dialog.result # Returns (tag_name, tag_message) or None - - if not tag_info: - # User cancelled the dialog - self.logger.info("Tag creation cancelled by user in dialog.") - return - - tag_name, tag_message = tag_info - self.logger.info(f"User provided tag: '{tag_name}', msg: '{tag_message}'") - - # --- Delegate Execution to ActionHandler --- + # 1. Commit outstanding changes IF message is provided try: - # ActionHandler manages pre-commit logic based on commit_msg presence - success = self.action_handler.execute_create_tag( - svn_path, commit_msg, tag_name, tag_message - ) - # execute_create_tag raises exceptions on failure - if success: # Should be true if no exception - self.logger.info(f"Tag '{tag_name}' created successfully.") - self.main_frame.show_info("Success", f"Tag '{tag_name}' created.") - self.refresh_tag_list() # Update list after successful creation - except ValueError as e: - # Catch specific errors like "commit message required" - self.logger.error(f"Tag creation validation failed: {e}") - self.main_frame.show_error("Tag Error", str(e)) - except GitCommandError as e: - # Catch Git command errors (from commit or tag) - self.logger.error(f"Tag creation failed (Git Error): {e}") - self.main_frame.show_error("Tag Error", f"Git command failed:\n{e}") - except Exception as e: - # Catch unexpected errors - self.logger.exception(f"Unexpected error creating tag: {e}") - self.main_frame.show_error("Error", f"Unexpected error:\n{e}") - - - def ui_checkout_tag(self): - """Callback for 'Checkout Selected Tag' button.""" - self.logger.info("--- Action Triggered: Checkout Tag ---") - svn_path = self._get_and_validate_svn_path("Checkout Tag") - if not svn_path: return - - selected_tag = self.main_frame.get_selected_tag() # Gets name - if not selected_tag: - self.main_frame.show_error("Selection Error", "Select a tag.") - return - - self.logger.info(f"Attempting checkout for tag: {selected_tag}") - - # Confirmation dialog first - msg = (f"Checkout tag '{selected_tag}'?\n\n" - f"WARNINGS:\n- Files WILL BE OVERWRITTEN.\n- NO backup created.\n" - f"- Enters 'detached HEAD' state.") - if not self.main_frame.ask_yes_no("Confirm Checkout", msg): - self.logger.info("Tag checkout cancelled."); return - - # Save settings before action? Optional. - if not self.ui_save_settings(): - self.logger.warning("Checkout Tag: Could not save profile settings.") - - # Delegate execution to ActionHandler - try: - success = self.action_handler.execute_checkout_tag(svn_path, selected_tag) - if success: - self.main_frame.show_info("Success", - f"Checked out tag '{selected_tag}'.\n\nNOTE: In 'detached HEAD'.") - # Update branch display after successful checkout - self.update_current_branch_display() - except ValueError as e: # Catch specific errors like "uncommitted changes" - self.main_frame.show_error("Checkout Blocked", str(e)) - except GitCommandError as e: # Catch Git command errors - self.main_frame.show_error("Error", f"Could not checkout:\n{e}") - except Exception as e: # Catch unexpected errors - self.logger.exception(f"Unexpected checkout error: {e}") - self.main_frame.show_error("Error", f"Unexpected checkout error:\n{e}") - - - def ui_delete_tag(self): - """Callback for 'Delete Selected Tag' button.""" - self.logger.info("--- Action Triggered: Delete Tag ---") - svn_path = self._get_and_validate_svn_path("Delete Tag") - if not svn_path: return - - selected_tag = self.main_frame.get_selected_tag() # Gets name - if not selected_tag: - self.main_frame.show_error("Selection Error", "Select a tag to delete.") - return - - # Confirmation dialog - msg = f"Delete tag '{selected_tag}' permanently?\nCannot be easily undone." - if not self.main_frame.ask_yes_no("Confirm Delete Tag", msg): - self.logger.info("Tag deletion cancelled.") - return - - self.logger.info(f"Attempting delete tag: {selected_tag}") - # Delegate execution to ActionHandler - try: - success = self.action_handler.execute_delete_tag(svn_path, selected_tag) - if success: - self.main_frame.show_info("Success", f"Tag '{selected_tag}' deleted.") - self.refresh_tag_list() # Update list - # else: action_handler raises error on failure + if self.git_commands.git_status_has_changes(svn_path): + self.logger.info("Uncommitted changes detected.") + commit_msg = self.main_frame.get_commit_message() + if not commit_msg: + self.logger.error("Tag blocked: Changes exist, no commit msg.") + self.main_frame.show_error( + "Commit Required", "Changes exist.\nEnter commit message first." + ) + return + confirm_msg = f"Commit changes with message:\n'{commit_msg}'?" + if not self.main_frame.ask_yes_no("Confirm Commit", confirm_msg): + self.logger.info("Commit cancelled.") + self.main_frame.show_warning("Cancelled", "Tag creation cancelled.") + return + if self.git_commands.git_commit(svn_path, commit_msg): + self.logger.info("Pre-tag commit successful.") + self.main_frame.clear_commit_message() # Clear after use + else: + self.logger.info("No changes detected before tagging.") except (GitCommandError, ValueError) as e: - # Handle known errors (not found, invalid name format?) - self.main_frame.show_error("Error", f"Could not delete tag:\n{e}") + self.logger.error(f"Pre-tag commit error: {e}") + self.main_frame.show_error("Commit Error", f"Failed:\n{e}") + return except Exception as e: - # Handle unexpected errors - self.logger.exception(f"Unexpected error deleting tag: {e}") - self.main_frame.show_error("Error", f"Unexpected error:\n{e}") + self.logger.exception(f"Unexpected pre-tag commit error: {e}") + self.main_frame.show_error("Error", f"Unexpected:\n{e}") + return + # 2. Open Dialog for Tag Name and Tag Message + self.logger.debug("Opening create tag dialog...") + dialog = CreateTagDialog(self.master) + tag_info = dialog.result # (name, message) or None - # --- Branch Management Callbacks --- - def refresh_branch_list(self): - """Refreshes the branch list in the GUI.""" - self.logger.info("--- Action: Refresh Branch List ---") - svn_path = self._get_and_validate_svn_path("Refresh Branches") - # Repo must be ready to list branches - is_ready = svn_path and os.path.exists(os.path.join(svn_path, ".git")) - if not is_ready: - if hasattr(self, 'main_frame'): self.main_frame.update_branch_list([]) - return + # 3. Create Tag if user provided info + if tag_info: + tag_name, tag_message = tag_info + self.logger.info(f"Creating tag '{tag_name}'...") + try: + self.git_commands.create_tag(svn_path, tag_name, tag_message) + self.logger.info(f"Tag '{tag_name}' created.") + self.main_frame.show_info("Success", f"Tag '{tag_name}' created.") + self.refresh_tag_list() # Refresh UI list + self.refresh_commit_history() # Show new tag in history + except (GitCommandError, ValueError) as e: + self.logger.error(f"Failed create tag '{tag_name}': {e}") + self.main_frame.show_error("Tag Error", f"Failed:\n{e}") + except Exception as e: + self.logger.exception(f"Unexpected tag error: {e}") + self.main_frame.show_error("Error", f"Unexpected:\n{e}") + else: + self.logger.info("Tag creation cancelled in dialog.") - # Fetch and update GUI + 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 + selected_tag = self.main_frame.get_selected_tag() # Gets name + if not selected_tag: + self.logger.warning("Checkout: No tag selected.") + self.main_frame.show_error("Error", "Select tag.") + return + self.logger.info(f"Attempting checkout: {selected_tag}") + try: # Check changes + if self.git_commands.git_status_has_changes(svn_path): + self.logger.error("Checkout blocked: Changes.") + self.main_frame.show_error("Blocked", "Changes exist.") + return + self.logger.debug("No changes found.") + except (GitCommandError, ValueError) as e: + self.logger.error(f"Status check error: {e}") + self.main_frame.show_error("Error", f"Status check:\n{e}") + return + except Exception as e: + self.logger.exception(f"Unexpected status check: {e}") + self.main_frame.show_error("Error", f"Unexpected status:\n{e}") + return + # Confirm + confirm_msg = f"Checkout tag '{selected_tag}'?\n\nWARNINGS:\n- Files overwritten.\n- NO backup.\n- Detached HEAD state." + if not self.main_frame.ask_yes_no("Confirm Checkout", confirm_msg): + self.logger.info("Checkout cancelled.") + return + self.logger.info(f"Confirmed checkout: {selected_tag}") + if not self.save_profile_settings(): + self.logger.warning("Checkout: Save failed.") + # Execute 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).") + if self.git_commands.checkout_tag(svn_path, selected_tag): + self.logger.info(f"Tag '{selected_tag}' checked out.") + self.main_frame.show_info( + "Success", + f"Checked out '{selected_tag}'.\n\nNOTE: Detached HEAD state.", + ) + self.refresh_branch_list() # Update current branch display + self.refresh_commit_history() # Update history view + except (GitCommandError, ValueError) as e: + self.logger.error(f"Failed checkout '{selected_tag}': {e}") + self.main_frame.show_error("Error", f"Checkout failed:\n{e}") + except Exception as e: + self.logger.exception(f"Unexpected checkout error: {e}") + self.main_frame.show_error("Error", f"Unexpected:\n{e}") + + # --- Branch Management Methods (from Branch Tab) --- + def refresh_branch_list(self): + """Fetches local branches and updates the Branch tab listbox.""" + self.logger.info("--- Action: Refresh Branches ---") + svn_path = self._get_and_validate_svn_path("Refresh Branches") + current_branch_for_history = None # Keep track for history filter update + if not svn_path or not os.path.exists(os.path.join(svn_path, ".git")): + if hasattr(self, "main_frame"): + self.main_frame.update_branch_list([], None) + self.main_frame.update_history_branch_filter( + [] + ) # Clear history filter too + return # Silently return if repo not ready + try: + branches, current = self.git_commands.list_branches(svn_path) + current_branch_for_history = current # Store current branch + if hasattr(self, "main_frame"): + self.main_frame.update_branch_list(branches, current) + # Update the history filter dropdown as well + self.main_frame.update_history_branch_filter(branches, current) + self.logger.info( + f"Branch list updated ({len(branches)} branches). Current: {current}" + ) except Exception as e: self.logger.error(f"Failed refresh branches: {e}", exc_info=True) self.main_frame.show_error("Error", f"Could not refresh branches:\n{e}") - if hasattr(self, 'main_frame'): - self.main_frame.update_branch_list([]) + if hasattr(self, "main_frame"): + self.main_frame.update_branch_list([], None) + self.main_frame.update_history_branch_filter([]) - - def update_current_branch_display(self): - """Gets the current branch and updates the display label.""" - self.logger.debug("Updating current branch display...") - svn_path = self._get_and_validate_svn_path("Update Branch Display") - current_branch_name = "" # Default value - - # Only query git if repo is ready - if svn_path and os.path.exists(os.path.join(svn_path, ".git")): - try: - # Use GitCommands to get branch name - branch_name = self.git_commands.get_current_branch(svn_path) - # Update display text based on result - if branch_name == "(DETACHED HEAD)": - current_branch_name = branch_name # Show detached state clearly - elif branch_name == "": - current_branch_name = "" # Show error state - elif branch_name: - current_branch_name = branch_name # Show actual branch name - else: - # Fallback if method returns None unexpectedly - current_branch_name = "" - except Exception as e: - # Handle exceptions during git command execution - self.logger.error(f"Failed to get current branch: {e}") - current_branch_name = "" - - # Update the GUI label via MainFrame method - if hasattr(self, 'main_frame'): - self.main_frame.set_current_branch_display(current_branch_name) - - - def ui_create_branch(self): - """Callback for 'Create Branch' button.""" - self.logger.info("--- Action Triggered: Create Branch ---") - svn_path = self._get_and_validate_svn_path("Create Branch") - if not svn_path: return - - # Use custom dialog to get new branch name - dialog = CreateBranchDialog(self.master) - new_branch_name = dialog.result # Returns name or None - if not new_branch_name: - self.logger.info("Branch creation cancelled."); return - - self.logger.info(f"Attempting create branch: '{new_branch_name}'") - # Save settings? Optional. - - # Delegate execution to ActionHandler - try: - # Assuming ActionHandler has execute_create_branch - # TODO: Add start_point logic later if needed - success = self.action_handler.execute_create_branch( - svn_path, new_branch_name - ) - if success: - self.main_frame.show_info("Success", f"Branch '{new_branch_name}' created.") - self.refresh_branch_list() # Update list - # Optionally ask to switch - except (GitCommandError, ValueError) as e: - self.main_frame.show_error("Error", f"Could not create branch:\n{e}") - except Exception as e: - self.logger.exception(f"Unexpected error creating branch: {e}") - self.main_frame.show_error("Error", f"Unexpected error:\n{e}") - - - def ui_switch_branch(self): - """Callback for 'Switch to Selected Branch' button.""" - self.logger.info("--- Action Triggered: Switch Branch ---") - svn_path = self._get_and_validate_svn_path("Switch Branch") - if not svn_path: return - - selected_branch = self.main_frame.get_selected_branch() # Gets name - if not selected_branch: - self.main_frame.show_error("Error", "Select a branch."); return - - # Prevent switching to the same branch - current_branch = self.main_frame.current_branch_var.get() - if selected_branch == current_branch: - self.main_frame.show_info("Info", f"Already on branch '{selected_branch}'.") + def checkout_branch(self): + """Handles the 'Checkout Selected Branch' action.""" + self.logger.info("--- Action: Checkout Branch ---") + svn_path = self._get_and_validate_svn_path("Checkout Branch") + if not svn_path: return - self.logger.info(f"Attempting switch to branch: {selected_branch}") - # Save settings? Optional. - - # Delegate execution (ActionHandler checks for changes) - try: - # Assuming ActionHandler has execute_switch_branch - success = self.action_handler.execute_switch_branch( - svn_path, selected_branch - ) - if success: - self.main_frame.show_info("Success", f"Switched to branch '{selected_branch}'.") - # Update UI after successful switch - self.update_current_branch_display() # Update label immediately - self.refresh_branch_list() # Update highlight in list - # else: Handler raises error - except ValueError as e: # Catch specific errors like uncommitted changes - self.main_frame.show_error("Switch Blocked", str(e)) - except GitCommandError as e: # Catch errors like branch not found - self.main_frame.show_error("Error", f"Could not switch branch:\n{e}") - except Exception as e: - self.logger.exception(f"Unexpected error switching branch: {e}") - self.main_frame.show_error("Error", f"Unexpected error:\n{e}") - - - def ui_delete_branch(self): - """Callback for 'Delete Selected Branch' button.""" - self.logger.info("--- Action Triggered: Delete Branch ---") - svn_path = self._get_and_validate_svn_path("Delete Branch") - if not svn_path: return - - selected_branch = self.main_frame.get_selected_branch() # Gets name + selected_branch = self.main_frame.get_selected_branch() if not selected_branch: - self.main_frame.show_error("Error", "Select a branch."); return + self.logger.warning("Checkout Branch: No branch selected.") + self.main_frame.show_error("Selection Error", "Select a branch.") + return - # Prevent deleting current or main branches - current_branch = self.main_frame.current_branch_var.get() - if selected_branch == current_branch: - self.main_frame.show_error("Error", "Cannot delete current branch."); return - if selected_branch in ["main", "master"]: - self.main_frame.show_error("Error", f"Cannot delete '{selected_branch}'."); return + self.logger.info(f"Attempting checkout for branch: {selected_branch}") - # Confirmation - msg = f"Delete local branch '{selected_branch}'?\nCannot be undone easily!" - if not self.main_frame.ask_yes_no("Confirm Delete Branch", msg): - self.logger.info("Branch deletion cancelled."); return - - self.logger.info(f"Attempting delete branch: {selected_branch}") - # Save settings? Unlikely needed. - - # Delegate execution + # Check for uncommitted changes try: - # Attempt safe delete first - success = self.action_handler.execute_delete_branch( - svn_path, selected_branch, force=False - ) - if success: - self.main_frame.show_info("Success", f"Branch '{selected_branch}' deleted.") - self.refresh_branch_list() # Update list - # else: Handler should raise error - - except GitCommandError as e: - # Handle specific errors, like 'not fully merged' - if "not fully merged" in str(e).lower(): - force_msg = (f"Branch '{selected_branch}' not fully merged.\n" - f"Force delete anyway (irreversible)?") - if self.main_frame.ask_yes_no("Force Delete?", force_msg): - try: - # Attempt force delete via ActionHandler - force_success = self.action_handler.execute_delete_branch( - svn_path, selected_branch, force=True - ) - if force_success: - self.main_frame.show_info("Success", f"Branch '{selected_branch}' force deleted.") - self.refresh_branch_list() - # else: Handler should raise error if force delete fails - except Exception as force_e: - self.logger.error(f"Force delete failed: {force_e}", exc_info=True) - self.main_frame.show_error("Error", f"Force delete failed:\n{force_e}") - else: - self.logger.info("Force delete cancelled.") - else: - # Other Git errors - self.main_frame.show_error("Error", f"Could not delete branch:\n{e}") + if self.git_commands.git_status_has_changes(svn_path): + self.logger.error("Checkout blocked: Uncommitted changes.") + self.main_frame.show_error( + "Blocked", "Uncommitted changes exist.\nCommit or stash first." + ) + return + self.logger.debug("No uncommitted changes found.") + except (GitCommandError, ValueError) as e: + self.logger.error(f"Status check error: {e}") + self.main_frame.show_error("Error", f"Cannot check status:\n{e}") + return except Exception as e: - self.logger.exception(f"Unexpected error deleting branch: {e}") - self.main_frame.show_error("Error", f"Unexpected error:\n{e}") + self.logger.exception(f"Unexpected status error: {e}") + self.main_frame.show_error("Error", f"Unexpected status:\n{e}") + return + # Confirm checkout + if not self.main_frame.ask_yes_no( + "Confirm Checkout", f"Switch to branch '{selected_branch}'?" + ): + self.logger.info("Branch checkout cancelled.") + return + + self.logger.info(f"Confirmed checkout: {selected_branch}") + if not self.save_profile_settings(): + self.logger.warning("Checkout: Save failed.") + + # Execute checkout + try: + if self.git_commands.checkout_branch(svn_path, selected_branch): + self.logger.info(f"Branch '{selected_branch}' checked out.") + self.main_frame.show_info( + "Success", f"Switched to branch '{selected_branch}'." + ) + self.refresh_branch_list() # Update list highlighting + self.refresh_commit_history() # Update history view + except (GitCommandError, ValueError) as e: + self.logger.error(f"Failed checkout '{selected_branch}': {e}") + self.main_frame.show_error("Error", f"Checkout failed:\n{e}") + except Exception as e: + self.logger.exception(f"Unexpected checkout error: {e}") + self.main_frame.show_error("Error", f"Unexpected:\n{e}") + + def create_branch(self): + """Handles the 'Create Branch' action.""" + self.logger.info("--- Action: Create Branch ---") + svn_path = self._get_and_validate_svn_path("Create Branch") + if not svn_path: + return + + # Open dialog to get new branch name + dialog = CreateBranchDialog(self.master) + new_branch_name = dialog.result + + if new_branch_name: + self.logger.info(f"User wants new branch: '{new_branch_name}'") + if not self.save_profile_settings(): + self.logger.warning("Create Branch: Save failed.") + + # Execute create command + try: + if self.git_commands.create_branch(svn_path, new_branch_name): + self.logger.info(f"Branch '{new_branch_name}' created.") + 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 to the new branch + if self.main_frame.ask_yes_no( + "Checkout New Branch?", + f"Switch to new branch '{new_branch_name}'?", + ): + # Perform checkout (no need for change check or confirmation here) + try: + if self.git_commands.checkout_branch( + svn_path, new_branch_name + ): + self.logger.info( + f"Checked out new branch '{new_branch_name}'." + ) + self.refresh_branch_list() # Update highlight again + self.refresh_commit_history() + # else: checkout failed, error logged by command + except (GitCommandError, ValueError) as chk_e: + self.logger.error(f"Failed checkout new branch: {chk_e}") + self.main_frame.show_error( + "Checkout Error", f"Failed switch:\n{chk_e}" + ) + except Exception as chk_e_unex: + self.logger.exception( + f"Unexpected checkout error: {chk_e_unex}" + ) + self.main_frame.show_error( + "Error", f"Unexpected switch:\n{chk_e_unex}" + ) + else: + # Refresh history even if not checking out + self.refresh_commit_history() + + except (GitCommandError, ValueError) as e: + self.logger.error(f"Failed create branch '{new_branch_name}': {e}") + self.main_frame.show_error("Error", f"Create failed:\n{e}") + except Exception as e: + self.logger.exception(f"Unexpected create branch: {e}") + self.main_frame.show_error("Error", f"Unexpected:\n{e}") + else: + self.logger.info("Branch creation cancelled.") + + # --- History Method (from History Tab) --- + def refresh_commit_history(self): + """Fetches commit log based on filters and updates display.""" + self.logger.info("--- Action: Refresh History ---") + svn_path = self._get_and_validate_svn_path("Refresh History") + if not svn_path or not os.path.exists(os.path.join(svn_path, ".git")): + if hasattr(self, "main_frame"): + self.main_frame.update_history_display([]) + return # Silently return if repo not ready + + # Get selected branch filter from GUI + branch_filter = None + if hasattr(self.main_frame, "history_branch_filter_var"): + selected_filter = self.main_frame.history_branch_filter_var.get() + if selected_filter and selected_filter != "-- All History --": + branch_filter = selected_filter # Use selected branch name + + self.logger.debug(f"Refreshing history with filter: {branch_filter}") + + try: + # Get log entries with optional filter + log_data = self.git_commands.get_commit_log( + svn_path, max_count=200, branch=branch_filter # Get more entries? + ) + if hasattr(self, "main_frame"): + self.main_frame.update_history_display(log_data) + self.logger.info(f"History updated ({len(log_data)} entries).") + except Exception as e: + self.logger.error(f"Failed refresh history: {e}", exc_info=True) + self.main_frame.show_error("Error", f"Could not refresh history:\n{e}") + if hasattr(self, "main_frame"): + self.main_frame.update_history_display([]) # --- GUI State Utilities --- def _clear_and_disable_fields(self): - """Clears relevant GUI fields and disables most buttons.""" - if hasattr(self, 'main_frame'): - mf = self.main_frame # Alias - # Clear Repo frame fields - mf.svn_path_entry.delete(0, tk.END) - mf.usb_path_entry.delete(0, tk.END) - mf.bundle_name_entry.delete(0, tk.END) - mf.bundle_updated_name_entry.delete(0, tk.END) - # Clear Commit/Tag/Branch frame fields - mf.commit_message_var.set("") - mf.autocommit_var.set(False) - mf.update_tag_list([]) # Clear tag listbox - mf.update_branch_list([]) # Clear branch listbox - mf.set_current_branch_display("") - # Reset indicator and dependent buttons/widgets - # This handles disabling Prepare, EditGitignore, Commit/Tag/Branch widgets - self.update_svn_status_indicator("") - # Disable general action buttons explicitly - self._disable_general_buttons() - self.logger.debug("GUI fields cleared/reset. Buttons disabled.") - + """Clears fields and disables most buttons when no profile/repo.""" + if hasattr(self, "main_frame"): + mf = self.main_frame + # Clear Repo Tab + 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 Tab + mf.clear_commit_message() + mf.autocommit_var.set(False) + # Clear Tags Tab + mf.update_tag_list([]) + # Clear Branch Tab + mf.update_branch_list([], None) + # Clear History Tab + mf.update_history_display([]) + mf.update_history_branch_filter([]) + # Update status (disables state-dependent widgets) + self.update_svn_status_indicator("") + # Disable general buttons + self._disable_general_buttons() + self.logger.debug("GUI fields cleared/reset. Buttons disabled.") def _disable_general_buttons(self): - """Disables buttons generally requiring only a loaded profile.""" - if hasattr(self, 'main_frame'): - # List of general action button attribute names in main_frame - button_names = [ - 'create_bundle_button', 'fetch_bundle_button', - 'manual_backup_button', 'save_settings_button' - ] - # Iterate and disable if the button exists - for name in button_names: - button = getattr(self.main_frame, name, None) - if button: - button.config(state=tk.DISABLED) - + """Disables general buttons requiring a profile.""" + if hasattr(self, "main_frame"): + # Only Save Settings is truly general now + button = getattr(self.main_frame, "save_settings_button", None) + if button: + button.config(state=tk.DISABLED) def _enable_function_buttons(self): - """ - Enables general action buttons. State-dependent buttons rely on - update_svn_status_indicator for their state. - """ - if hasattr(self, 'main_frame'): + """Enables general buttons. State buttons handled by status update.""" + if hasattr(self, "main_frame"): general_state = tk.NORMAL - # List of general action button attribute names - button_names = [ - 'create_bundle_button', 'fetch_bundle_button', - 'manual_backup_button', 'save_settings_button' - ] - # Iterate and enable if the button exists - for name in button_names: - button = getattr(self.main_frame, name, None) - if button: - button.config(state=general_state) - - # Ensure state-dependent buttons reflect the current status - # This call updates Prepare, EditGitignore, Commit/Tag/Branch widget states - current_svn_path = "" - if hasattr(self.main_frame, 'svn_path_entry'): - current_svn_path = self.main_frame.svn_path_entry.get() - self.update_svn_status_indicator(current_svn_path) + button = getattr(self.main_frame, "save_settings_button", None) + if button: + button.config(state=general_state) + # Trigger state update for all repo-dependent widgets + self.update_svn_status_indicator(self.main_frame.svn_path_entry.get()) self.logger.debug("General buttons enabled. State buttons updated.") - def show_fatal_error(self, message): """Shows a fatal error message.""" try: - # Determine parent window safely - parent = None - if hasattr(self, 'master') and self.master and self.master.winfo_exists(): - parent = self.master - messagebox.showerror("Fatal Error", message, parent=parent) - except tk.TclError: - # Fallback if GUI is not ready or fails - print(f"FATAL ERROR: {message}") + parent = ( + self.master + if hasattr(self, "master") + and self.master + and self.master.winfo_exists() + else None + ) + messagebox.showerror("Fatal Error", message, parent=parent) 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 + print(f"FATAL ERROR (+GUI error {e}): {message}") - return os.path.join(base_path, relative_path) # --- Application Entry Point --- def main(): """Main function: Creates Tkinter root and runs the application.""" + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s:%(message)s" + ) root = tk.Tk() - # Adjust min size for the new layout with tabs - root.minsize(750, 650) # Adjusted min height after tabbing - - # --- Imposta l'icona della finestra --- - try: - # Assumendo che 'app_icon.ico' sia nella stessa dir dello script - # o aggiunto correttamente a PyInstaller - icon_path = resource_path("GitUtility.ico") - # Usa wm_iconbitmap per Windows - if os.path.exists(icon_path): - # wm_iconbitmap si aspetta un file .ico su Windows - # Per Linux/Mac, si userebbe iconphoto con un PhotoImage (PNG) - if os.name == 'nt': # Solo per Windows - root.wm_iconbitmap(icon_path) - else: - # Su Linux/Mac potresti usare iconphoto con un file PNG - # icon_img = tk.PhotoImage(file=resource_path("app_icon.png")) - # root.iconphoto(True, icon_img) # 'True' per default icon - # Nota: Dovresti aggiungere app_icon.png con --add-data - pass # Per ora non facciamo nulla su altri OS - else: - # Log se l'icona non viene trovata nel percorso atteso - logging.warning(f"Window icon file not found at: {icon_path}") - except tk.TclError as e: - # Logga se c'è un errore nel caricare/impostare l'icona - logging.warning(f"Could not set window icon: {e}") - except Exception as e: - # Logga altri errori imprevisti - logging.warning(f"Unexpected error setting window icon: {e}", exc_info=True) - - app = None # Initialize app variable + # Adjust min height slightly for history + root.minsize(750, 800) + app = None try: app = GitSvnSyncApp(root) - # Start main loop only if initialization likely succeeded - if hasattr(app, 'main_frame') and app.main_frame: - root.mainloop() + 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() + print("App init failed.") + 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 + logging.exception("Fatal error during startup/mainloop.") 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) + parent = root if root and root.winfo_exists() else None + messagebox.showerror("Fatal Error", f"App failed:\n{e}", parent=parent) except Exception as msg_e: - print(f"FATAL ERROR (GUI error: {msg_e}): App failed:\n{e}") + print(f"FATAL ERROR (GUI error: {msg_e}):\n{e}") finally: - # Log application exit regardless of success or failure - logging.info("Application exiting.") + logging.info("Application exiting.") if __name__ == "__main__": - # Setup basic logging immediately at startup - log_format = "%(asctime)s - %(levelname)s - [%(module)s:%(funcName)s:%(lineno)d] - %(message)s" - # Consider level=logging.DEBUG for more detail during development - logging.basicConfig(level=logging.INFO, format=log_format) - main() \ No newline at end of file + main() diff --git a/GitUtility.spec b/GitUtility.spec index 3ac9cf1..fef37a4 100644 --- a/GitUtility.spec +++ b/GitUtility.spec @@ -1,44 +1,17 @@ # -*- mode: python ; coding: utf-8 -*- +block_cipher = None -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) +a = Analysis(scripts=['C:\\src\\____GitProjects\\GitUtility\\GitUtility.py'], pathex=['C:\\src\\____GitProjects\\GitUtility'], binaries=[], datas=[('C:\\src\\____GitProjects\\GitUtility\\git_svn_sync.ini', '.')], hiddenimports=[], hookspath=[], runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=None, noarchive=False,) -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', -) +pyz = PYZ(a.pure, a.zipped_data, cipher=None) + +exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='GitUtility', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False,) +coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='GitUtility',) diff --git a/action_handler.py b/action_handler.py index 2333770..e740f62 100644 --- a/action_handler.py +++ b/action_handler.py @@ -6,10 +6,13 @@ import os from git_commands import GitCommands, GitCommandError from backup_handler import BackupHandler + class ActionHandler: """Handles the execution logic for core application actions.""" - def __init__(self, logger, git_commands: GitCommands, backup_handler: BackupHandler): + def __init__( + self, logger, git_commands: GitCommands, backup_handler: BackupHandler + ): """ Initializes the ActionHandler. @@ -22,10 +25,15 @@ 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): + def _perform_backup_if_enabled( + self, + svn_path, + profile_name, + autobackup_enabled, + backup_base_dir, + excluded_extensions, + excluded_dirs, + ): """ Performs backup if enabled. Raises IOError on backup failure. @@ -42,7 +50,7 @@ class ActionHandler: """ if not autobackup_enabled: self.logger.debug("Autobackup disabled, skipping backup.") - return # Backup not needed, proceed successfully + return # Backup not needed, proceed successfully self.logger.info("Autobackup enabled. Starting backup...") try: @@ -53,18 +61,17 @@ class ActionHandler: backup_base_dir, profile_name, excluded_extensions, - excluded_dirs + excluded_dirs, ) # 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 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 - + # 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 def execute_prepare_repo(self, svn_path): """ @@ -98,11 +105,18 @@ class ActionHandler: self.logger.error(f"Failed to prepare repository: {e}", exc_info=True) raise - - def execute_create_bundle(self, svn_path, bundle_full_path, profile_name, - autobackup_enabled, backup_base_dir, - autocommit_enabled, commit_message, - excluded_extensions, excluded_dirs): + def execute_create_bundle( + self, + svn_path, + bundle_full_path, + profile_name, + autobackup_enabled, + backup_base_dir, + autocommit_enabled, + commit_message, + excluded_extensions, + excluded_dirs, + ): """ Executes 'create bundle', including backup and commit logic. @@ -126,8 +140,12 @@ class ActionHandler: # --- Backup Step --- # _perform_backup_if_enabled raises IOError on failure self._perform_backup_if_enabled( - svn_path, profile_name, autobackup_enabled, backup_base_dir, - excluded_extensions, excluded_dirs + svn_path, + profile_name, + autobackup_enabled, + backup_base_dir, + excluded_extensions, + excluded_dirs, ) # --- Autocommit Step --- @@ -139,9 +157,14 @@ class ActionHandler: if has_changes: self.logger.info("Changes detected, performing autocommit...") # Use provided message or generate default - 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}'") + 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, returns bool) self.git_commands.git_commit(svn_path, commit_msg_to_use) # Log based on return value? git_commit already logs detail. @@ -162,27 +185,33 @@ class ActionHandler: 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.") - return bundle_full_path # Return path on success + self.logger.info("Git bundle created successfully.") + return bundle_full_path # Return path on success else: - # Bundle empty or not created (logged by GitCommands) - self.logger.warning("Bundle file not created or is empty.") - if bundle_exists and not bundle_not_empty: - # Clean up empty file - try: - os.remove(bundle_full_path) - except OSError: - self.logger.warning("Could not remove empty bundle file.") - return None # Indicate non-fatal issue (empty bundle) + # Bundle empty or not created (logged by GitCommands) + self.logger.warning("Bundle file not created or is empty.") + if bundle_exists and not bundle_not_empty: + # Clean up empty file + try: + os.remove(bundle_full_path) + except OSError: + 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 - self.logger.error(f"Bundle creation failed: {bundle_e}", exc_info=True) - raise bundle_e + # Log and re-raise bundle creation errors + self.logger.error(f"Bundle creation failed: {bundle_e}", exc_info=True) + raise bundle_e - - def execute_fetch_bundle(self, svn_path, bundle_full_path, profile_name, - autobackup_enabled, backup_base_dir, - excluded_extensions, excluded_dirs): + def execute_fetch_bundle( + self, + svn_path, + bundle_full_path, + profile_name, + autobackup_enabled, + backup_base_dir, + excluded_extensions, + excluded_dirs, + ): """ Executes the 'fetch bundle' action, including backup logic. @@ -195,8 +224,12 @@ class ActionHandler: # --- Backup Step --- # Raises IOError on failure self._perform_backup_if_enabled( - svn_path, profile_name, autobackup_enabled, backup_base_dir, - excluded_extensions, excluded_dirs + svn_path, + profile_name, + autobackup_enabled, + backup_base_dir, + excluded_extensions, + excluded_dirs, ) # --- Fetch and Merge Step --- @@ -207,10 +240,9 @@ class ActionHandler: self.logger.info("Fetch/merge process completed successfully.") # 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) - raise fetch_e - + # Log and re-raise any error from fetch/merge + self.logger.error(f"Fetch/merge failed: {fetch_e}", exc_info=True) + raise fetch_e def execute_manual_commit(self, svn_path, commit_message): """ @@ -227,9 +259,9 @@ class ActionHandler: GitCommandError/Exception: If commit command fails. """ if not commit_message: - # 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.") + # 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.") self.logger.info(f"Executing manual commit for: {svn_path}") try: @@ -242,7 +274,6 @@ class ActionHandler: self.logger.error(f"Manual commit failed: {e}", exc_info=True) raise - def execute_create_tag(self, svn_path, commit_message, tag_name, tag_message): """ Executes tag creation, including pre-commit using commit_message if needed. @@ -270,22 +301,23 @@ class ActionHandler: # Block if changes exist but no message provided msg = "Changes exist. Commit message required before tagging." self.logger.error(f"Tag creation blocked: {msg}") - raise ValueError(msg) # Raise error for UI layer + raise ValueError(msg) # Raise error for UI layer # 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 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}).") + self.logger.info( + f"Pre-tag commit attempt finished (made={commit_made})." + ) else: self.logger.info("No uncommitted changes detected before tagging.") except Exception as e: # Catch errors during status check or commit - self.logger.error(f"Error during pre-tag commit step: {e}", - exc_info=True) - raise # Re-raise commit-related errors + self.logger.error(f"Error during pre-tag commit step: {e}", exc_info=True) + raise # Re-raise commit-related errors # --- Create Tag Step --- self.logger.info(f"Proceeding to create tag '{tag_name}'...") @@ -294,13 +326,11 @@ class ActionHandler: # It raises ValueError for invalid name, GitCommandError for exists/fail self.git_commands.create_tag(svn_path, tag_name, tag_message) self.logger.info(f"Tag '{tag_name}' created successfully.") - return True # Indicate success + return True # Indicate success except Exception as e: # Catch errors during tag creation - self.logger.error(f"Failed to create tag '{tag_name}': {e}", - exc_info=True) - raise # Re-raise tag creation errors - + self.logger.error(f"Failed to create tag '{tag_name}': {e}", exc_info=True) + raise # Re-raise tag creation errors def execute_checkout_tag(self, svn_path, tag_name): """ @@ -327,31 +357,30 @@ class ActionHandler: if has_changes: msg = "Uncommitted changes exist. Commit or stash first." self.logger.error(f"Checkout blocked: {msg}") - raise ValueError(msg) # Raise specific error for UI + 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 checkout: {e}", - exc_info=True) - raise # Re-raise status check errors + # Catch errors during status check + self.logger.error(f"Status check error before checkout: {e}", exc_info=True) + raise # Re-raise status check errors # --- Execute Checkout --- try: # git_commands.checkout_tag raises error on failure checkout_success = self.git_commands.checkout_tag(svn_path, tag_name) if checkout_success: - self.logger.info(f"Tag '{tag_name}' checked out.") - return True + 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.") + # 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}", - exc_info=True) - raise # Re-raise checkout errors - + self.logger.error( + f"Failed to checkout tag '{tag_name}': {e}", exc_info=True + ) + raise # Re-raise checkout errors # --- Branch Actions --- def execute_create_branch(self, svn_path, branch_name, start_point=None): @@ -363,8 +392,10 @@ class ActionHandler: Returns: True on success. Raises: GitCommandError/ValueError/Exception on failure. """ - self.logger.info(f"Executing create branch '{branch_name}' " - f"from '{start_point or 'HEAD'}'.") + self.logger.info( + f"Executing create branch '{branch_name}' " + f"from '{start_point or 'HEAD'}'." + ) try: # Delegate to git_commands, raises error on failure self.git_commands.create_branch(svn_path, branch_name, start_point) @@ -374,7 +405,6 @@ class ActionHandler: self.logger.error(f"Failed to create branch: {e}", exc_info=True) raise - def execute_switch_branch(self, svn_path, branch_name): """ Executes branch switch after checking for changes. @@ -394,13 +424,12 @@ 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 for UI + 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 + # Catch errors during status check + self.logger.error(f"Status check error before switch: {e}", exc_info=True) + raise # Re-raise status check errors # --- Execute Switch --- try: @@ -409,11 +438,11 @@ class ActionHandler: 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) + self.logger.error( + f"Failed switch to branch '{branch_name}': {e}", exc_info=True + ) raise - def execute_delete_branch(self, svn_path, branch_name, force=False): """ Executes branch deletion. @@ -434,11 +463,11 @@ class ActionHandler: return success except Exception as e: # Catch errors (like not merged, needs force) - self.logger.error(f"Failed delete branch '{branch_name}': {e}", - exc_info=True) + self.logger.error( + f"Failed delete branch '{branch_name}': {e}", exc_info=True + ) raise - def execute_delete_tag(self, svn_path, tag_name): """ Executes deletion for the specified tag. @@ -461,9 +490,8 @@ class ActionHandler: # Delegate deletion to GitCommands method # This raises GitCommandError if tag not found or other git error success = self.git_commands.delete_tag(svn_path, tag_name) - return success # Should be True if no exception + return success # Should be True if no exception except Exception as e: # Catch and re-raise errors from git_commands.delete_tag - self.logger.error(f"Failed to delete tag '{tag_name}': {e}", - exc_info=True) - raise # Re-raise for the UI layer to handle \ No newline at end of file + self.logger.error(f"Failed to delete tag '{tag_name}': {e}", exc_info=True) + raise # Re-raise for the UI layer to handle diff --git a/backup_handler.py b/backup_handler.py index cd7b623..186b97b 100644 --- a/backup_handler.py +++ b/backup_handler.py @@ -4,6 +4,7 @@ import datetime import zipfile import logging + class BackupHandler: """Handles the creation of ZIP backups with exclusions.""" @@ -20,8 +21,14 @@ class BackupHandler: # to ConfigManager based on the current profile selected in the UI. # The create_zip_backup method now receives the parsed exclusions directly. - def create_zip_backup(self, source_repo_path, backup_base_dir, - profile_name, excluded_extensions, excluded_dirs_base): + def create_zip_backup( + self, + source_repo_path, + backup_base_dir, + profile_name, + excluded_extensions, + excluded_dirs_base, + ): """ Creates a timestamped ZIP backup of the source repository directory, respecting provided exclusions. @@ -60,16 +67,17 @@ class BackupHandler: # exist_ok=True prevents error if directory already exists os.makedirs(backup_base_dir, exist_ok=True) except OSError as e: - self.logger.error(f"Cannot create backup directory: {e}", - exc_info=True) + self.logger.error(f"Cannot create backup directory: {e}", exc_info=True) # Re-raise as IOError for the caller to potentially handle differently raise IOError(f"Could not create backup directory: {e}") from e # --- 2. Construct Backup Filename --- now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") # Sanitize profile name for use in filename (remove potentially invalid chars) - safe_profile = "".join(c for c in profile_name - if c.isalnum() or c in '_-').rstrip() or "profile" + 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}") @@ -78,13 +86,13 @@ class BackupHandler: files_added = 0 files_excluded = 0 dirs_excluded = 0 - zip_f = None # Initialize zip file object outside try block + zip_f = None # Initialize zip file object outside try block try: # Open ZIP file with settings for compression and large files - zip_f = zipfile.ZipFile(backup_full_path, 'w', - compression=zipfile.ZIP_DEFLATED, - allowZip64=True) + zip_f = zipfile.ZipFile( + backup_full_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True + ) # Walk through the source directory tree for root, dirs, files in os.walk(source_repo_path, topdown=True): @@ -101,8 +109,8 @@ class BackupHandler: 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}") + path_excluded = os.path.join(root, ex_dir) + self.logger.debug(f"Excluding directory: {path_excluded}") # --- File Exclusion and Addition --- for filename in files: @@ -111,12 +119,14 @@ class BackupHandler: file_ext_lower = file_ext.lower() # Check exclusion rules (case-insensitive filename or extension) - if filename.lower() in excluded_dirs_base or \ - file_ext_lower in excluded_extensions: + 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 + continue # Skip this file # If not excluded, add file to ZIP file_full_path = os.path.join(root, filename) @@ -129,14 +139,14 @@ class BackupHandler: files_added += 1 # Log progress periodically for large backups if files_added % 500 == 0: - self.logger.debug(f"Added {files_added} files...") + self.logger.debug(f"Added {files_added} files...") except Exception as write_e: - # Log error writing a specific file but allow backup to continue - self.logger.error( - f"Error writing file '{file_full_path}' to ZIP: {write_e}", - exc_info=True - ) - # Consider marking the backup as potentially incomplete + # Log error writing a specific file but allow backup to continue + self.logger.error( + f"Error writing file '{file_full_path}' to ZIP: {write_e}", + exc_info=True, + ) + # Consider marking the backup as potentially incomplete # Log final summary after successful walk and write attempts self.logger.info(f"Backup ZIP creation finished: {backup_full_path}") @@ -148,15 +158,15 @@ class BackupHandler: return backup_full_path except (OSError, zipfile.BadZipFile) as e: - # Handle OS errors (permissions, disk space) and ZIP format errors - self.logger.error(f"Error creating backup ZIP: {e}", exc_info=True) - # Re-raise as IOError for the caller to potentially handle specifically - raise IOError(f"Failed to create backup ZIP: {e}") from e + # Handle OS errors (permissions, disk space) and ZIP format errors + self.logger.error(f"Error creating backup ZIP: {e}", exc_info=True) + # Re-raise as IOError for the caller to potentially handle specifically + raise IOError(f"Failed to create backup ZIP: {e}") from e except Exception as e: - # Catch any other unexpected error during the process - self.logger.exception(f"Unexpected error during ZIP backup: {e}") - # Re-raise the original exception - raise + # Catch any other unexpected error during the process + self.logger.exception(f"Unexpected error during ZIP backup: {e}") + # Re-raise the original exception + raise finally: # Ensure the ZIP file is always closed, even if errors occurred if zip_f: @@ -167,16 +177,16 @@ class BackupHandler: zip_exists = os.path.exists(backup_full_path) # Check if zip exists but no files were actually added if zip_exists 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, but don't stop execution - self.logger.error(f"Failed remove empty backup ZIP: {rm_e}") + 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, but don't stop execution + self.logger.error(f"Failed remove empty backup ZIP: {rm_e}") elif not zip_exists and files_added > 0: - # This case indicates an issue if files were supposedly added - # but the zip file doesn't exist at the end (perhaps deleted?) - self.logger.error("Backup process finished but ZIP file missing.") - # Consider raising an error here if this state is critical \ No newline at end of file + # This case indicates an issue if files were supposedly added + # but the zip file doesn't exist at the end (perhaps deleted?) + self.logger.error("Backup process finished but ZIP file missing.") + # Consider raising an error here if this state is critical diff --git a/git_commands.py b/git_commands.py index 46590fa..a3cdac4 100644 --- a/git_commands.py +++ b/git_commands.py @@ -2,88 +2,103 @@ import os import subprocess import logging -import re # Ensure re is imported +import re # Modulo per espressioni regolari (usato per validazione nomi) + +# --- Definizione Eccezione Personalizzata --- class GitCommandError(Exception): """ Custom exception for handling Git command errors. - Includes the original command and error details if available. + Includes the original command and stderr details if available. """ + def __init__(self, message, command=None, stderr=None): """ Initialize the GitCommandError. Args: - message (str): The error message. - command (list, optional): The command that caused the error. Defaults to None. - stderr (str, optional): The standard error output. Defaults to None. + message (str): The main error message. + command (list, optional): The command list that caused the error. + stderr (str, optional): The standard error output from the command. """ super().__init__(message) self.command = command self.stderr = stderr def __str__(self): - """Return a formatted string representation of the error.""" + """Return a formatted string representation including command details.""" base_message = super().__str__() details = [] if self.command: - # Ensure command is list of strings for join + # Safely convert all command parts to string for joining safe_command = [str(part) for part in self.command] - command_str = ' '.join(safe_command) + command_str = " ".join(safe_command) details.append(f"Command: '{command_str}'") if self.stderr: + # Add stripped stderr if available stderr_str = self.stderr.strip() - details.append(f"Stderr: {stderr_str}") + if stderr_str: # Only add if stderr has content + details.append(f"Stderr: {stderr_str}") + # Combine base message with details if details exist if details: - details_str = '; '.join(details) + details_str = "; ".join(details) return f"{base_message} ({details_str})" else: return base_message +# --- Classe Principale per Comandi Git --- class GitCommands: """ - Manages Git commands execution, logging, and error handling. - Includes tag and branch management functionalities. + Manages Git command execution, logging, and error handling. + Decoupled from the GUI, operates on provided directory paths. + Includes functionalities for core operations, tags, and branches. """ + def __init__(self, logger): """ - Initializes the GitCommands with a logger. + Initializes the GitCommands class with a logger instance. Args: - logger (logging.Logger): Logger instance for logging messages. + logger (logging.Logger): Instance for logging messages. + + Raises: + ValueError: If the provided logger is not a valid logging.Logger. """ if not isinstance(logger, logging.Logger): - # Raise error if logger is not a valid logger instance - raise ValueError("A valid logging.Logger instance is required.") + raise ValueError("A valid logging.Logger instance is required.") self.logger = logger def log_and_execute(self, command, working_directory, check=True): """ - Executes a command within a specific working directory, logs it, - and handles errors. + Executes a shell command in a specific directory, logs details, + and handles potential errors. Args: - command (list): List representing the command and its arguments. - working_directory (str): Path to the directory for command execution. - check (bool, optional): Raise error on non-zero exit code if True. - Defaults to True. + command (list): Command and arguments as a list of strings. + working_directory (str): The directory to execute the command in. + check (bool, optional): If True, raises CalledProcessError (wrapped + in GitCommandError) if the command returns + a non-zero exit code. Defaults to True. Returns: - subprocess.CompletedProcess: The result object from subprocess.run. + subprocess.CompletedProcess: Result object containing stdout, stderr, etc. Raises: - GitCommandError: If path invalid, command fails (and check=True), etc. - ValueError: If working_directory is None or empty. + GitCommandError: For Git-specific errors or execution issues. + ValueError: If working_directory is invalid. + FileNotFoundError: If the command (e.g., 'git') is not found. + PermissionError: If execution permission is denied. """ - # Ensure all parts of command are strings for logging and execution + # Ensure all parts of command are strings for reliable execution and logging safe_command = [str(part) for part in command] - command_str = ' '.join(safe_command) + command_str = " ".join(safe_command) log_message = f"Executing: {command_str}" + # Log command execution at debug level for less verbosity in standard logs self.logger.debug(log_message) - # Validate working directory + # --- Validate Working Directory --- if not working_directory: msg = "Working directory cannot be None or empty." self.logger.error(msg) @@ -93,675 +108,705 @@ class GitCommands: if not os.path.isdir(abs_path): msg = f"Working directory does not exist or is not a directory: {abs_path}" self.logger.error(msg) - # Include command context in the error + # Raise GitCommandError to signal issue related to command execution context raise GitCommandError(msg, command=safe_command) cwd = abs_path - self.logger.debug(f"Working directory: {cwd}") + self.logger.debug(f"Effective Working Directory: {cwd}") + # --- Execute Command --- try: # Platform-specific setup to hide console window on Windows startupinfo = None - creationflags = 0 # Default creation flags - if os.name == 'nt': + # creationflags are used to control process creation (e.g., hide window) + creationflags = 0 + if os.name == "nt": # Windows specific settings startupinfo = subprocess.STARTUPINFO() + # Prevent console window from showing startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE - # Optional: Use CREATE_NO_WINDOW if std I/O redirection not needed + # Alternative flag to completely detach from console (might affect stdio) # creationflags = subprocess.CREATE_NO_WINDOW - # Execute the command + # Run the command using subprocess.run result = subprocess.run( - safe_command, # Use the validated command list - cwd=cwd, - capture_output=True, - text=True, - check=check, - encoding='utf-8', - errors='replace', # Handle potential decoding errors - startupinfo=startupinfo, - creationflags=creationflags + safe_command, # Use the command list with string parts + cwd=cwd, # Set working directory + capture_output=True, # Capture stdout and stderr + text=True, # Decode output as text (UTF-8 default) + check=check, # Raise exception on non-zero exit code if True + encoding="utf-8", # Specify encoding explicitly + errors="replace", # Handle potential decoding errors gracefully + startupinfo=startupinfo, # Windows: hide console window + creationflags=creationflags, # Windows: additional process flags ) - # Log stdout and stderr clearly + # Log command output for debugging and info stdout_log = result.stdout.strip() if result.stdout else "" stderr_log = result.stderr.strip() if result.stderr else "" - # Log success with output details - # Use DEBUG level for full output, INFO for summary? Let's use INFO for now. - self.logger.info( - f"Command successful. Output:\n" - f"--- stdout ---\n{stdout_log}\n" - f"--- stderr ---\n{stderr_log}\n---" - ) + # Log success differently based on whether check=True was used + # If check=True, CalledProcessError would have been raised on failure + # If check=False, we log success only if return code is 0 + if check or result.returncode == 0: + self.logger.info( + f"Command successful. Output:\n" + f"--- stdout ---\n{stdout_log}\n" + f"--- stderr ---\n{stderr_log}\n---" + ) + # Note: If check=False and returncode != 0, errors are typically + # handled by the calling method that analyzes the result. + return result except subprocess.CalledProcessError as e: - # Log detailed error information from the failed process + # This block runs only if check=True and the command failed stderr_err = e.stderr.strip() if e.stderr else "" stdout_err = e.stdout.strip() if e.stdout else "" error_log_msg = ( f"Command failed with return code {e.returncode} in '{cwd}'.\n" f"--- command ---\n{command_str}\n" f"--- stderr ---\n{stderr_err}\n" - f"--- stdout ---\n{stdout_err}\n" - f"---" + f"--- stdout ---\n{stdout_err}\n---" ) self.logger.error(error_log_msg) - # Wrap the original exception for consistent error handling + # Wrap the original exception in our custom GitCommandError raise GitCommandError( - f"Git command failed in '{cwd}'.", - command=safe_command, - stderr=e.stderr - ) from e + f"Git command failed in '{cwd}'.", command=safe_command, stderr=e.stderr + ) from e # Preserve original exception context except FileNotFoundError as e: - # Log error if the command itself (e.g., 'git') is not found - error_msg = ( - f"Command not found: '{safe_command[0]}'. Is Git installed " - f"and in system PATH? (WD: '{cwd}')" - ) - self.logger.error(error_msg) - # Log exception details at debug level for more info if needed - self.logger.debug(f"FileNotFoundError details: {e}") - raise GitCommandError(error_msg, command=safe_command) from e + # Handle error if 'git' command (or another part) is not found + error_msg = ( + f"Command not found: '{safe_command[0]}'. Is Git installed " + f"and in system PATH? (Working directory: '{cwd}')" + ) + self.logger.error(error_msg) + self.logger.debug( + f"FileNotFoundError details: {e}" + ) # Log full details only at debug level + raise GitCommandError(error_msg, command=safe_command) from e except PermissionError as e: - # Handle errors related to execution permissions - error_msg = f"Permission denied executing command in '{cwd}'." - self.logger.error(error_msg) - self.logger.debug(f"PermissionError details: {e}") - raise GitCommandError(error_msg, command=safe_command, stderr=str(e)) from e + # Handle errors due to lack of execution permissions + error_msg = f"Permission denied executing command in '{cwd}'." + self.logger.error(error_msg) + self.logger.debug(f"PermissionError details: {e}") + raise GitCommandError(error_msg, command=safe_command, stderr=str(e)) from e except Exception as e: - # Catch any other unexpected errors during execution + # Catch any other unexpected errors during subprocess execution self.logger.exception(f"Unexpected error executing command in '{cwd}': {e}") raise GitCommandError( - f"Unexpected error during command execution: {e}", - command=safe_command + f"Unexpected command execution error: {e}", command=safe_command ) from e - - def create_git_bundle(self, working_directory, bundle_path): + # --- Core Repo Operations --- + def prepare_svn_for_git(self, working_directory): """ - Creates a Git bundle file from the repository in working_directory. + Prepares a directory for Git use: initializes if needed, ensures + .gitignore ignores '.svn'. Args: - working_directory (str): Path to the local Git repository. - bundle_path (str): Full path where the bundle file should be saved. + working_directory (str): Path to the target directory. Raises: - GitCommandError: If command fails or path invalid. - ValueError: If working_directory is None or empty. + GitCommandError, ValueError: If operations fail. """ + self.logger.info(f"Preparing directory for Git: '{working_directory}'") + # Basic validation + if not working_directory: + raise ValueError("Working directory cannot be None or empty.") + if not os.path.isdir(working_directory): + raise GitCommandError(f"Directory does not exist: {working_directory}") + + # Define relevant paths + gitignore_path = os.path.join(working_directory, ".gitignore") + git_dir_path = os.path.join(working_directory, ".git") + + # 1. Initialize Git repository if '.git' directory doesn't exist + if not os.path.exists(git_dir_path): + self.logger.info("No existing Git repository found. Initializing...") + try: + init_command = ["git", "init"] + # Use check=True to ensure init succeeds + self.log_and_execute(init_command, working_directory, check=True) + self.logger.info("Git repository initialized successfully.") + except (GitCommandError, ValueError) as e: + # Handle specific errors from git init + self.logger.error(f"Failed to initialize Git repository: {e}") + raise # Re-raise to signal preparation failure + except Exception as e: + # Handle unexpected errors during init + self.logger.exception(f"Unexpected error initializing repository: {e}") + raise GitCommandError(f"Unexpected init error: {e}") from e + else: + # Repository already exists + self.logger.info("Git repository already exists. Skipping initialization.") + + # 2. Ensure .gitignore handles '.svn' + self.logger.debug(f"Checking/updating .gitignore file: {gitignore_path}") + try: + svn_ignore_entry = ".svn" # Entry to ensure is ignored + needs_write = False # Flag to track if file needs modification + content_to_write = "" # Content to add if needed + + if not os.path.exists(gitignore_path): + # .gitignore doesn't exist, create it with the entry + self.logger.info( + "'.gitignore' file not found. Creating with .svn entry." + ) + content_to_write = f"{svn_ignore_entry}\n" + needs_write = True + else: + # .gitignore exists, check if '.svn' is already ignored + try: + with open(gitignore_path, "r", encoding="utf-8") as f: + lines = f.readlines() + # Check ignores .svn directory or files within it + is_ignored = any( + line.strip() == svn_ignore_entry + or line.strip().startswith(svn_ignore_entry + "/") + for line in lines + ) + if not is_ignored: + # Not ignored, prepare to append the entry + self.logger.info( + f"'{svn_ignore_entry}' not found. Appending..." + ) + current_content = "".join(lines) + # Ensure it's added on a new line + if not current_content.endswith("\n"): + content_to_write = f"\n{svn_ignore_entry}\n" + else: + content_to_write = f"{svn_ignore_entry}\n" + needs_write = True # Mark file for update + else: + # Already ignored, no action needed + self.logger.info(f"'{svn_ignore_entry}' entry already present.") + except IOError as e: + # Handle error reading the existing file + self.logger.warning( + f"Could not read existing '.gitignore': {e}. " + f"Cannot verify {svn_ignore_entry} entry." + ) + # Decide on recovery: maybe try appending anyway? Risky. + # For safety, let's not modify if we can't read it. + needs_write = False + + # Write to file only if modification is necessary + if needs_write: + # Use 'a'ppend mode if file exists, 'w'rite mode if new + mode = "a" if os.path.exists(gitignore_path) else "w" + try: + # Write with consistent newline handling + with open( + gitignore_path, mode, encoding="utf-8", newline="\n" + ) as f: + f.write(content_to_write) + self.logger.info("Updated '.gitignore' file successfully.") + except IOError as e: + # Handle error during write/append + self.logger.error(f"Error writing to '.gitignore': {e}") + raise GitCommandError(f"Failed to update .gitignore: {e}") from e + + except IOError as e: # Catch errors from os.path.exists or final write attempt + self.logger.error(f"Error accessing or writing '.gitignore': {e}") + raise GitCommandError(f"File I/O error for .gitignore: {e}") from e + except Exception as e: # Catch other unexpected errors + self.logger.exception(f"Unexpected error managing '.gitignore': {e}") + raise GitCommandError(f"Unexpected error with .gitignore: {e}") from e + + self.logger.info(f"Directory preparation complete for '{working_directory}'.") + + def create_git_bundle(self, working_directory, bundle_path): + """Creates a Git bundle file containing all refs.""" + # Normalize path for cross-OS compatibility and command usage normalized_bundle_path = os.path.normpath(bundle_path) normalized_bundle_path = normalized_bundle_path.replace("\\", "/") command = ["git", "bundle", "create", normalized_bundle_path, "--all"] - self.logger.info(f"Attempting to create Git bundle: {normalized_bundle_path}") + self.logger.info(f"Attempting to create bundle: {normalized_bundle_path}") try: - result = self.log_and_execute( - command, - working_directory, - check=False # Check result manually - ) + # Use check=False to manually handle 'empty bundle' warning + result = self.log_and_execute(command, working_directory, check=False) if result.returncode != 0: + # Command failed, check stderr for specific non-fatal cases stderr_lower = result.stderr.lower() if result.stderr else "" if "refusing to create empty bundle" in stderr_lower: - self.logger.warning(f"Bundle creation skipped: Empty repo at '{working_directory}'.") - # Not an error, do not raise + # This is a warning, not an error for our purposes + self.logger.warning( + f"Bundle creation skipped: No commits in '{working_directory}'." + ) + # Do not raise error, let caller handle info else: - error_msg = f"Git bundle command failed (code {result.returncode})." - raise GitCommandError(error_msg, command, result.stderr) - elif not os.path.exists(normalized_bundle_path) or \ - os.path.getsize(normalized_bundle_path) == 0: - self.logger.warning(f"Bundle success, but file '{normalized_bundle_path}' missing/empty.") - # Consider if this is an error case + # An actual error occurred + error_msg = f"Bundle command failed (code {result.returncode})." + raise GitCommandError( + error_msg, command=command, stderr=result.stderr + ) + # Check file existence and size even on success (paranoid check) + elif ( + not os.path.exists(normalized_bundle_path) + or os.path.getsize(normalized_bundle_path) == 0 + ): + self.logger.warning(f"Bundle cmd success, but file missing/empty.") else: - self.logger.info(f"Git bundle created successfully: '{normalized_bundle_path}'.") + self.logger.info( + f"Bundle created successfully: '{normalized_bundle_path}'." + ) except (GitCommandError, ValueError) as e: - self.logger.error(f"Failed create bundle for '{working_directory}': {e}") - raise + self.logger.error(f"Failed create bundle for '{working_directory}': {e}") + raise except Exception as e: - self.logger.exception(f"Unexpected bundle error for '{working_directory}': {e}") - raise GitCommandError(f"Unexpected bundle error: {e}", command) from e - + self.logger.exception(f"Unexpected bundle creation error: {e}") + raise GitCommandError( + f"Unexpected bundle error: {e}", command=command + ) from e def fetch_from_git_bundle(self, working_directory, bundle_path): - """ - Fetches changes from a Git bundle file and merges them. - - Args: - working_directory (str): Path to the local Git repository. - bundle_path (str): Path to the Git bundle file. - - Raises: - GitCommandError: If fetch or merge fails. - ValueError: If arguments invalid. - """ + """Fetches from a bundle file and merges fetched refs.""" normalized_bundle_path = os.path.normpath(bundle_path).replace("\\", "/") - self.logger.info(f"Fetching from '{normalized_bundle_path}' into '{working_directory}'") + self.logger.info( + f"Fetching from '{normalized_bundle_path}' into '{working_directory}'" + ) fetch_command = ["git", "fetch", normalized_bundle_path] - merge_command = ["git", "merge", "FETCH_HEAD", "--no-ff"] # No fast-forward merge + # Merge FETCH_HEAD, creating merge commit if necessary (--no-ff) + merge_command = ["git", "merge", "FETCH_HEAD", "--no-ff"] try: - # 1. Fetch changes - self.logger.debug("Executing fetch command...") - self.log_and_execute(fetch_command, working_directory, check=True) # Error if fetch fails - self.logger.info("Successfully fetched from Git bundle.") + # 1. Fetch + self.logger.debug("Executing fetch...") + self.log_and_execute(fetch_command, working_directory, check=True) + self.logger.info("Fetch successful.") - # 2. Merge fetched changes - self.logger.debug("Executing merge command...") - merge_result = self.log_and_execute(merge_command, working_directory, check=False) # Check manually + # 2. Merge + self.logger.debug("Executing merge...") + # Use check=False as conflicts are expected failures + merge_result = self.log_and_execute( + merge_command, working_directory, check=False + ) # Analyze merge result stdout_log = merge_result.stdout.strip() if merge_result.stdout else "" stderr_log = merge_result.stderr.strip() if merge_result.stderr else "" if merge_result.returncode == 0: + # Success or already up-to-date if "already up to date" in stdout_log.lower(): self.logger.info("Repository already up-to-date.") else: - self.logger.info("Successfully merged fetched changes.") + self.logger.info("Merge successful.") else: # Merge failed, likely conflicts output_lower = (stderr_log + stdout_log).lower() if "conflict" in output_lower: - conflict_msg = (f"Merge conflict after fetching. Resolve manually " - f"in '{working_directory}' and commit.") - self.logger.error(conflict_msg) - # Raise specific error for caller - raise GitCommandError(conflict_msg, merge_command, merge_result.stderr) + conflict_msg = ( + f"Merge conflict occurred. Resolve manually in " + f"'{working_directory}' and commit." + ) + self.logger.error(conflict_msg) + # Raise specific error for caller to handle + raise GitCommandError( + conflict_msg, command=merge_command, stderr=merge_result.stderr + ) else: - # Other merge error - error_msg = f"Merge command failed (code {merge_result.returncode})." - self.logger.error(error_msg) - raise GitCommandError(error_msg, merge_command, merge_result.stderr) - + # Other merge error + error_msg = f"Merge failed (code {merge_result.returncode})." + self.logger.error(error_msg) + raise GitCommandError( + error_msg, command=merge_command, stderr=merge_result.stderr + ) except (GitCommandError, ValueError) as e: self.logger.error(f"Fetch/merge error for '{working_directory}': {e}") raise except Exception as e: - self.logger.exception(f"Unexpected fetch/merge error for '{working_directory}': {e}") + self.logger.exception(f"Unexpected fetch/merge error: {e}") raise GitCommandError(f"Unexpected fetch/merge error: {e}") from e + # --- Commit and Status --- + def git_commit(self, working_directory, message): + """Stages all changes and commits them with the given message.""" + self.logger.info( + f"Attempting commit in '{working_directory}' with msg: '{message}'" + ) + if not message: + # Disallow empty commit messages programmatically + raise ValueError("Commit message cannot be empty.") - def prepare_svn_for_git(self, working_directory): - """ - Prepares a directory for Git: initializes repo and ensures .gitignore. - - Args: - working_directory (str): Path to the directory. - - Raises: - GitCommandError/ValueError/IOError: On failure. - """ - self.logger.info(f"Preparing directory for Git: '{working_directory}'") - - # Validate path - if not working_directory: - raise ValueError("Working directory cannot be empty.") - if not os.path.isdir(working_directory): - raise GitCommandError(f"Directory does not exist: {working_directory}") - - # Define paths - gitignore_path = os.path.join(working_directory, ".gitignore") - git_dir_path = os.path.join(working_directory, ".git") - - # 1. Initialize Git repository if needed - if not os.path.exists(git_dir_path): - self.logger.info("No existing Git repo found. Initializing...") - try: - init_command = ["git", "init"] - self.log_and_execute(init_command, working_directory, check=True) - self.logger.info("Git repository initialized successfully.") - except (GitCommandError, ValueError) as e: - self.logger.error(f"Failed to initialize Git repository: {e}") - raise # Re-raise to signal failure - except Exception as e: - self.logger.exception(f"Unexpected error initializing repo: {e}") - raise GitCommandError(f"Unexpected init error: {e}") from e - else: - self.logger.info("Git repository already exists. Skipping init.") - - # 2. Ensure .gitignore exists and ignores .svn - self.logger.debug(f"Checking/updating .gitignore: {gitignore_path}") - try: - svn_ignore_entry = ".svn" - needs_write = False - content_to_write = "" - - if not os.path.exists(gitignore_path): - # File doesn't exist, create it with the entry - self.logger.info("'.gitignore' not found. Creating with .svn entry.") - content_to_write = f"{svn_ignore_entry}\n" - needs_write = True - else: - # File exists, check content - try: - with open(gitignore_path, "r", encoding='utf-8') as f: - lines = f.readlines() - # Check if entry or entry/ exists - is_ignored = any( - line.strip() == svn_ignore_entry or \ - line.strip().startswith(svn_ignore_entry + '/') - for line in lines - ) - if not is_ignored: - # Entry not found, need to append - self.logger.info(f"'{svn_ignore_entry}' not found. Appending.") - current_content = "".join(lines) - # Add newline before entry if file doesn't end with one - if not current_content.endswith('\n'): - content_to_write = f"\n{svn_ignore_entry}\n" - else: - content_to_write = f"{svn_ignore_entry}\n" - needs_write = True - else: - self.logger.info(f"'{svn_ignore_entry}' already ignored.") - except IOError as e: - # Cannot read existing file, log warning, don't modify - self.logger.warning(f"Could not read existing '.gitignore': {e}.") - - # Write to file only if necessary - if needs_write: - # Use 'a'ppend mode if appending, 'w'rite if creating new - mode = 'a' if os.path.exists(gitignore_path) and content_to_write.startswith("\n") else 'w' - # If creating new, content shouldn't start with newline - if mode == 'w': - content_to_write = content_to_write.lstrip() - - try: - with open(gitignore_path, mode, encoding='utf-8', newline='\n') as f: - f.write(content_to_write) - self.logger.info("Updated '.gitignore' file.") - except IOError as e: - self.logger.error(f"Error writing to '.gitignore': {e}") - raise GitCommandError(f"Failed update .gitignore: {e}") from e - - except IOError as e: # Catch errors from os.path.exists or final write - self.logger.error(f"Error accessing/writing '.gitignore': {e}") - raise GitCommandError(f"File I/O error for .gitignore: {e}") from e - except Exception as e: # Catch other unexpected errors - self.logger.exception(f"Unexpected error managing '.gitignore': {e}") - raise GitCommandError(f"Unexpected error with .gitignore: {e}") from e - - self.logger.info(f"Directory preparation complete for '{working_directory}'.") - - - def git_commit(self, working_directory, message="Autocommit"): - """ Stages all changes and commits them. """ - self.logger.info(f"Attempting commit in '{working_directory}': '{message}'") try: - # 1. Stage all changes + # 1. Stage all changes using 'git add .' add_command = ["git", "add", "."] - self.logger.debug("Staging all changes (git add .)...") + self.logger.debug("Staging all changes...") + # Use check=True to ensure staging succeeds self.log_and_execute(add_command, working_directory, check=True) self.logger.debug("Staging successful.") # 2. Commit staged changes commit_command = ["git", "commit", "-m", message] self.logger.debug("Attempting commit...") - result = self.log_and_execute(commit_command, working_directory, check=False) + # Use check=False to handle 'nothing to commit' case gracefully + result = self.log_and_execute( + commit_command, working_directory, check=False + ) - # Analyze result + # Analyze commit result stdout_lower = result.stdout.lower() if result.stdout else "" stderr_lower = result.stderr.lower() if result.stderr else "" - nothing_to_commit = False - if "nothing to commit" in stdout_lower: nothing_to_commit = True - if "no changes added to commit" in stdout_lower: nothing_to_commit = True - if "nothing added to commit" in stdout_lower: nothing_to_commit = True - # Handle Git returning 1 with no output for no changes case - if result.returncode == 1 and not stderr_lower and not stdout_lower: nothing_to_commit = True if result.returncode == 0: - self.logger.info("Commit successful.") - return True - elif nothing_to_commit: - self.logger.info("No changes to commit.") - return False + # Standard successful commit + self.logger.info("Commit successful.") + return True + # Check for various 'nothing to commit' messages + elif ( + "nothing to commit" in stdout_lower + or "no changes added to commit" in stdout_lower + or "nothing added to commit" in stdout_lower + or (result.returncode == 1 and not stderr_lower and not stdout_lower) + ): + # Common non-error case: repo was clean or add didn't stage anything new + self.logger.info("No changes were available to commit.") + return False # Indicate no commit was made else: - # Unexpected error - error_msg = f"Commit command failed (code {result.returncode})." - raise GitCommandError(error_msg, commit_command, result.stderr) + # An unexpected error occurred during the commit + error_msg = ( + f"Commit cmd failed unexpectedly (code {result.returncode})." + ) + # Details likely logged by log_and_execute already + raise GitCommandError( + error_msg, command=commit_command, stderr=result.stderr + ) except (GitCommandError, ValueError) as e: - self.logger.error(f"Commit process error: {e}") - raise + # Catch errors from staging or commit steps, or validation + self.logger.error(f"Error during staging or commit: {e}") + raise # Re-raise the specific error except Exception as e: - self.logger.exception(f"Unexpected commit error: {e}") + # Catch any other unexpected error + self.logger.exception(f"Unexpected error during commit process: {e}") raise GitCommandError(f"Unexpected commit error: {e}") from e - def git_status_has_changes(self, working_directory): - """ Checks if the Git repository has uncommitted changes. """ + """Checks if the Git repository has uncommitted changes.""" self.logger.debug(f"Checking Git status in '{working_directory}'...") try: + # Use '--porcelain' for reliable script parsing status_command = ["git", "status", "--porcelain"] + # Use check=True to ensure the status command itself runs correctly result = self.log_and_execute(status_command, working_directory, check=True) - # Any output from porcelain means changes exist + # If porcelain output is non-empty, there are changes has_changes = bool(result.stdout.strip()) self.logger.debug(f"Status check complete. Has changes: {has_changes}") return has_changes except (GitCommandError, ValueError) as e: self.logger.error(f"Status check error: {e}") - raise + raise # Re-raise specific error except Exception as e: - self.logger.exception(f"Unexpected status error: {e}") - raise GitCommandError(f"Unexpected status error: {e}") from e - + self.logger.exception(f"Unexpected status check error: {e}") + raise GitCommandError(f"Unexpected status error: {e}") from e + # --- Tag Management --- def list_tags(self, working_directory): - """ Lists tags with subjects, sorted newest first. """ + """Lists tags with subjects, sorted by creation date (desc).""" self.logger.info(f"Listing tags with subjects in '{working_directory}'...") - format_string = "%(refname:short)%09%(contents:subject)" - command = ["git", "tag", "--list", f"--format={format_string}", "--sort=-creatordate"] - tags_with_subjects = [] - try: - result = self.log_and_execute(command, working_directory, check=True) - output_lines = result.stdout.splitlines() - for line in output_lines: - line_stripped = line.strip() - if line_stripped: - parts = line_stripped.split('\t', 1) # Split only on first tab - tag_name = parts[0].strip() - tag_subject = parts[1].strip() if len(parts) > 1 else "(No subject)" - tags_with_subjects.append((tag_name, tag_subject)) - - count = len(tags_with_subjects) - self.logger.info(f"Found {count} tags with subjects.") - self.logger.debug(f"Tags found: {tags_with_subjects}") - return tags_with_subjects - except (GitCommandError, ValueError) as e: - self.logger.error(f"Error listing tags: {e}") - return [] # Return empty list on known errors - except Exception as e: - self.logger.exception(f"Unexpected error listing tags: {e}") - return [] - - - def create_tag(self, working_directory, tag_name, message): - """ Creates an annotated tag. """ - self.logger.info(f"Creating tag '{tag_name}' in '{working_directory}'") - # Validate inputs - if not tag_name: raise ValueError("Tag name cannot be empty.") - if not message: raise ValueError("Tag message cannot be empty.") - # Validate tag name format - pattern = r"^(?![./]|.*([./]{2,}|[.]$|\.lock$))[^ \t\n\r\f\v~^:?*[\\]+(?" - except (GitCommandError, ValueError) as e: - self.logger.error(f"Error getting current branch: {e}") - return "" - except Exception as e: - self.logger.exception(f"Unexpected error getting branch: {e}") - return "" - - def list_branches(self, working_directory): - """ Lists local Git branches. """ - self.logger.info(f"Listing local branches in '{working_directory}'...") - cmd = ["git", "branch", "--list", "--no-color"] - branches = [] + # Format: TagnameSubjectLine + fmt = "%(refname:short)%09%(contents:subject)" + cmd = ["git", "tag", "--list", f"--format={fmt}", "--sort=-creatordate"] + tags_data = [] # List to store (name, subject) tuples try: result = self.log_and_execute(cmd, working_directory, check=True) for line in result.stdout.splitlines(): - # Remove decoration ('*' ) and whitespace - name = line.lstrip('* ').strip() - # Filter out potential detached HEAD message if present - if name and "HEAD detached" not in name: - branches.append(name) - count = len(branches) - self.logger.info(f"Found {count} local branches.") - self.logger.debug(f"Branches: {branches}") - return branches + line_strip = line.strip() + if line_strip: + # Split only on the first tab to handle subjects with tabs + parts = line_strip.split("\t", 1) + name = parts[0].strip() + # Provide default if subject is missing + subject = parts[1].strip() if len(parts) > 1 else "(No subject)" + tags_data.append((name, subject)) + self.logger.info(f"Found {len(tags_data)} tags.") + self.logger.debug(f"Tags found: {tags_data}") + return tags_data except (GitCommandError, ValueError) as e: - self.logger.error(f"Error listing branches: {e}") + # Log error but return empty list for graceful GUI handling + self.logger.error(f"Error listing tags: {e}") return [] except Exception as e: - self.logger.exception(f"Unexpected error listing branches: {e}") + # Log unexpected errors + self.logger.exception(f"Unexpected error listing tags: {e}") return [] - def create_branch(self, working_directory, branch_name, start_point=None): - """ Creates a new local Git branch. """ - self.logger.info(f"Creating branch '{branch_name}' in '{working_directory}'...") - if not branch_name: - raise ValueError("Branch name cannot be empty.") - # Branch name validation - pattern = r"^(?![./]|.*([./]{2,}|[.]$|[/]$|@\{))[^ \t\n\r\f\v~^:?*[\\]+(? Save and Close 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 + elif response is False: # No -> Discard and Close + self.logger.warning("Discarding .gitignore changes.") + self.destroy() + # Else (Cancel): Do nothing, window stays open else: - # No changes detected, simply close the window + # No changes, simply close self.destroy() + + # --- End Gitignore Editor Window --- # --- Create Tag Dialog --- class CreateTagDialog(simpledialog.Dialog): - """ Dialog to get new tag name and message. """ + """Dialog to get new tag name and message.""" + def __init__(self, parent, title="Create New Tag"): """Initialize the dialog.""" self.tag_name_var = tk.StringVar() self.tag_message_var = tk.StringVar() - self.result = None # Stores (name, message) tuple on success - # Call Dialog constructor AFTER initializing variables + self.result = None super().__init__(parent, title=title) def body(self, master): """Create dialog body with input fields.""" name_label = ttk.Label(master, text="Tag Name:") name_label.grid(row=0, column=0, padx=5, pady=5, sticky="w") - - self.name_entry = ttk.Entry( - master, - textvariable=self.tag_name_var, - width=40 - ) + self.name_entry = ttk.Entry(master, textvariable=self.tag_name_var, width=40) self.name_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew") - message_label = ttk.Label(master, text="Tag Message:") - message_label.grid(row=1, column=0, padx=5, pady=5, sticky="w") - + msg_label = ttk.Label(master, text="Tag Message:") + msg_label.grid(row=1, column=0, padx=5, pady=5, sticky="w") self.message_entry = ttk.Entry( - master, - textvariable=self.tag_message_var, - width=40 + master, textvariable=self.tag_message_var, width=40 ) self.message_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew") - # Configure column to allow entry widgets to expand horizontally - master.columnconfigure(1, weight=1) - # Return the widget that should have initial focus - return self.name_entry + master.columnconfigure(1, weight=1) # Allow entries to expand + return self.name_entry # Initial focus def validate(self): - """Validate the input fields are not empty and name format is valid.""" + """Validate the input fields.""" name = self.tag_name_var.get().strip() message = self.tag_message_var.get().strip() - - # Check for empty fields if not name: - messagebox.showwarning("Input Error", "Tag name cannot be empty.", - parent=self) - return 0 # Fail validation + messagebox.showwarning( + "Input Error", "Tag name cannot be empty.", parent=self + ) + return 0 # Validation failed if not message: - messagebox.showwarning("Input Error", "Tag message cannot be empty.", - parent=self) - return 0 # Fail validation + messagebox.showwarning( + "Input Error", "Tag message cannot be empty.", parent=self + ) + return 0 # Validation failed - # Validate tag name format using regex (ensure 're' is imported) - pattern = r"^(?![./]|.*([./]{2,}|[.]$|\.lock$|@\{))[^ \t\n\r\f\v~^:?*[\\]+(?") + self.autocommit_var = tk.BooleanVar() # For autocommit before bundle + # Commit message uses ScrolledText widget directly now - # --- Create Main Layout Sections --- + # --- Create UI Elements --- + # Profile selection frame (always visible) self._create_profile_frame() - self._create_notebook_with_tabs() - self._create_function_frame() + + # Notebook for tabs + self.notebook = ttk.Notebook(self, padding=(0, 5, 0, 0)) + self.notebook.pack(pady=(5, 0), padx=0, fill="both", expand=True) + + # Create frames for each tab (methods return the frame) + self.repo_tab_frame = self._create_repo_tab() + self.backup_tab_frame = self._create_backup_tab() + self.commit_tab_frame = self._create_commit_tab() + self.tags_tab_frame = self._create_tags_tab() + self.branch_tab_frame = self._create_branch_tab() + self.history_tab_frame = self._create_history_tab() + + # Add frames as tabs to the notebook + self.notebook.add(self.repo_tab_frame, text=" Repository / Bundle ") + self.notebook.add(self.backup_tab_frame, text=" Backup ") + self.notebook.add(self.commit_tab_frame, text=" Commit ") + self.notebook.add(self.tags_tab_frame, text=" Tags ") + self.notebook.add(self.branch_tab_frame, text=" Branches ") + self.notebook.add(self.history_tab_frame, text=" History ") + + # Log area (always visible below tabs) self._create_log_area() # --- Initial State Configuration --- self._initialize_profile_selection() - self.toggle_backup_dir() - + self.toggle_backup_dir() # Set initial state of backup dir widgets + # Status updates are triggered by initial profile load def _create_profile_frame(self): - """Creates the frame for profile selection and management.""" - self.profile_frame = ttk.LabelFrame( - self, text="Profile Configuration", padding=(10, 5) - ) - self.profile_frame.pack(pady=(0, 5), fill="x") - self.profile_frame.columnconfigure(1, weight=1) - - profile_label = ttk.Label(self.profile_frame, text="Profile:") - profile_label.grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + """Creates the frame for profile configuration (above tabs).""" + profile_outer_frame = ttk.Frame(self, padding=(0, 0, 0, 5)) + profile_outer_frame.pack(fill="x") + frame = ttk.LabelFrame(profile_outer_frame, text="Profile", padding=(10, 5)) + frame.pack(fill="x") + frame.columnconfigure(1, weight=1) # Dropdown expands + # Label + lbl = ttk.Label(frame, text="Profile:") + lbl.grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + # Combobox self.profile_dropdown = ttk.Combobox( - self.profile_frame, textvariable=self.profile_var, - state="readonly", width=35, values=self.initial_profile_sections + 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) self.profile_dropdown.bind( "<>", - lambda e: self.load_profile_settings_callback(self.profile_var.get()) + lambda e: self.load_profile_settings_callback(self.profile_var.get()), ) self.profile_var.trace_add( "write", - lambda *a: self.load_profile_settings_callback(self.profile_var.get()) + lambda *a: self.load_profile_settings_callback(self.profile_var.get()), ) - + # Buttons self.save_settings_button = ttk.Button( - self.profile_frame, text="Save Settings", - command=self.save_profile_callback + frame, text="Save Settings", command=self.save_profile_callback ) - 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 settings for selected profile.") - + 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 settings.") self.add_profile_button = ttk.Button( - self.profile_frame, text="Add", width=5, - command=self.add_profile_callback + 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) - + self.add_profile_button.grid(row=0, column=3, sticky=tk.W, padx=(2, 0), pady=5) self.remove_profile_button = ttk.Button( - self.profile_frame, text="Remove", width=8, - command=self.remove_profile_callback + 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 ) - self.remove_profile_button.grid(row=0, column=4, sticky=tk.W, - padx=(2, 5), pady=5) + def _create_repo_tab(self): + """Creates the frame for the 'Repository / Bundle' tab.""" + frame = ttk.Frame(self.notebook, padding=(10, 10)) + frame.columnconfigure(1, weight=1) # Allow entries to expand - 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 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_tab_frame) - backup_config_frame.pack(pady=5, fill="x", expand=False) - - - def _create_repo_paths_frame(self, parent): - """Creates the sub-frame for repository paths and bundle names.""" - frame = ttk.LabelFrame(parent, text="Repository & Bundle Paths", - padding=(10, 5)) - col_label = 0 - col_entry = 1 - col_button = 2 - col_indicator = 3 - # Configure entry column to expand horizontally - frame.columnconfigure(col_entry, weight=1) - - # Row 0: SVN Path - svn_label = ttk.Label(frame, text="SVN Working Copy:") - svn_label.grid(row=0, column=col_label, sticky=tk.W, padx=5, pady=3) - 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())) - # Button uses local browse_folder method - self.svn_path_browse_button = ttk.Button( - 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 - 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)") - - # 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 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) - - # 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) - 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) - self.bundle_updated_name_entry.grid(row=3, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3) - - 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)) + # --- Paths and Bundle Names --- + paths_frame = ttk.LabelFrame( + frame, text="Paths & Bundle Names", padding=(10, 5) + ) + paths_frame.pack(pady=5, fill="x") + paths_frame.columnconfigure(1, weight=1) # Entry column expands col_label = 0 col_entry = 1 col_button = 2 - frame.columnconfigure(col_entry, weight=1) # Entry expands + col_indicator = 3 - # 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 + # SVN Path + lbl_svn = ttk.Label(paths_frame, text="SVN Working Copy:") + lbl_svn.grid(row=0, column=col_label, sticky=tk.W, padx=5, pady=3) + self.svn_path_entry = ttk.Entry(paths_frame, width=60) + self.svn_path_entry.grid(row=0, column=col_entry, sticky=tk.EW, padx=5, pady=3) + self.svn_path_entry.bind( + "", + lambda e: self.update_svn_status_callback(self.svn_path_entry.get()), ) - self.autobackup_checkbox.grid(row=0, column=col_label, columnspan=3, sticky=tk.W, padx=5, pady=(5, 0)) - - # 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.svn_path_entry.bind( + "", + lambda e: self.update_svn_status_callback(self.svn_path_entry.get()), ) - 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, + self.svn_path_browse_button = ttk.Button( + paths_frame, text="Browse...", width=9, - command=self.browse_backup_dir, # Call local method - state=tk.DISABLED + command=lambda: self.browse_folder_callback(self.svn_path_entry), ) - self.backup_dir_button.grid(row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=5) + self.svn_path_browse_button.grid( + row=0, column=col_button, sticky=tk.W, padx=(0, 5), pady=3 + ) + self.svn_status_indicator = tk.Label( + paths_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)") - # Row 2: Exclude Extensions - exclude_label = ttk.Label(frame, text="Exclude Extensions:") + # Bundle Target Dir + lbl_usb = ttk.Label(paths_frame, text="Bundle Target Dir:") + lbl_usb.grid(row=1, column=col_label, sticky=tk.W, padx=5, pady=3) + self.usb_path_entry = ttk.Entry(paths_frame, width=60) + self.usb_path_entry.grid(row=1, column=col_entry, sticky=tk.EW, padx=5, pady=3) + self.usb_path_browse_button = ttk.Button( + paths_frame, + text="Browse...", + width=9, + command=lambda: self.browse_folder_callback(self.usb_path_entry), + ) + self.usb_path_browse_button.grid( + row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=3 + ) + + # Create Bundle Name + lbl_create_b = ttk.Label(paths_frame, text="Create Bundle Name:") + lbl_create_b.grid(row=2, column=col_label, sticky=tk.W, padx=5, pady=3) + self.bundle_name_entry = ttk.Entry(paths_frame, width=60) + self.bundle_name_entry.grid( + row=2, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3 + ) + + # Fetch Bundle Name + lbl_fetch_b = ttk.Label(paths_frame, text="Fetch Bundle Name:") + lbl_fetch_b.grid(row=3, column=col_label, sticky=tk.W, padx=5, pady=3) + self.bundle_updated_name_entry = ttk.Entry(paths_frame, width=60) + self.bundle_updated_name_entry.grid( + row=3, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3 + ) + + # --- Actions Frame --- + actions_frame = ttk.LabelFrame(frame, text="Actions", padding=(10, 5)) + actions_frame.pack(pady=10, fill="x") + + # Prepare Button + self.prepare_svn_button = ttk.Button( + actions_frame, + text="Prepare SVN Repo", + command=self.prepare_svn_for_git_callback, + state=tk.DISABLED, + ) + self.prepare_svn_button.pack(side=tk.LEFT, padx=(0, 5), pady=5) + self.create_tooltip(self.prepare_svn_button, "Initialize Git & .gitignore") + + # Create Bundle Button + self.create_bundle_button = ttk.Button( + actions_frame, + text="Create Bundle", + command=self.create_git_bundle_callback, + state=tk.DISABLED, + ) + self.create_bundle_button.pack(side=tk.LEFT, padx=5, pady=5) + self.create_tooltip(self.create_bundle_button, "Create Git bundle file") + + # Fetch Bundle Button + self.fetch_bundle_button = ttk.Button( + actions_frame, + text="Fetch from Bundle", + command=self.fetch_from_git_bundle_callback, + state=tk.DISABLED, + ) + self.fetch_bundle_button.pack(side=tk.LEFT, padx=5, pady=5) + self.create_tooltip(self.fetch_bundle_button, "Fetch & merge from bundle") + + # Edit .gitignore Button (Moved here) + self.edit_gitignore_button = ttk.Button( + actions_frame, + text="Edit .gitignore", + width=12, + command=self.open_gitignore_editor_callback, + state=tk.DISABLED, + ) + self.edit_gitignore_button.pack(side=tk.LEFT, padx=5, pady=5) + self.create_tooltip(self.edit_gitignore_button, "Edit .gitignore file") + + return frame + + def _create_backup_tab(self): + """Creates the frame for the 'Backup' tab.""" + frame = ttk.Frame(self.notebook, padding=(10, 10)) + frame.columnconfigure(1, weight=1) # Entry expands + + # --- Configuration --- + config_frame = ttk.LabelFrame(frame, text="Configuration", padding=(10, 5)) + config_frame.pack(pady=5, fill="x") + config_frame.columnconfigure(1, weight=1) + col_label = 0 + col_entry = 1 + col_button = 2 + + self.autobackup_checkbox = ttk.Checkbutton( + config_frame, + text="Auto Backup before Create/Fetch", + variable=self.autobackup_var, + command=self.toggle_backup_dir, + ) + self.autobackup_checkbox.grid( + row=0, column=0, columnspan=3, sticky=tk.W, padx=5, pady=(5, 0) + ) + backup_dir_label = ttk.Label(config_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( + config_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( + config_frame, + text="Browse...", + width=9, + command=self.browse_backup_dir, + state=tk.DISABLED, + ) + self.backup_dir_button.grid( + row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=5 + ) + exclude_label = ttk.Label(config_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 + config_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 (e.g., .log,.tmp)") + 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)" + ) + + # --- Manual Backup Action --- + action_frame = ttk.LabelFrame(frame, text="Manual Backup", padding=(10, 5)) + action_frame.pack(pady=10, fill="x") + self.manual_backup_button = ttk.Button( + action_frame, + text="Backup Now (ZIP)", + command=self.manual_backup_callback, + state=tk.DISABLED, + ) + self.manual_backup_button.pack(side=tk.LEFT, padx=5, pady=5) + self.create_tooltip(self.manual_backup_button, "Create a ZIP backup now.") return frame + def _create_commit_tab(self): + """Creates the frame for the 'Commit' tab.""" + frame = ttk.Frame(self.notebook, padding=(10, 10)) + frame.rowconfigure(2, weight=1) # Text area expands + frame.columnconfigure(0, weight=1) # Text area expands - def _populate_commit_branch_tab(self, parent_tab_frame): - """Creates and places widgets for the Commit & Branches tab.""" - # 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 - - # 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)) - - # 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): - """Creates the sub-frame for commit message and actions.""" - frame = ttk.LabelFrame(parent, text="Commit", padding=5) - # Configure internal columns - frame.columnconfigure(1, weight=1) # Entry expands - - # Row 0: Autocommit Checkbox + # Row 0: Autocommit Checkbox (for Create Bundle action) self.autocommit_checkbox = ttk.Checkbutton( frame, - text="Autocommit before 'Create Bundle' (uses message below)", + text="Autocommit changes before 'Create Bundle' action", variable=self.autocommit_var, - state=tk.DISABLED # State depends on repo readiness + state=tk.DISABLED, + ) + self.autocommit_checkbox.grid( + row=0, column=0, columnspan=2, sticky="w", padx=5, pady=(0, 5) + ) + self.create_tooltip( + self.autocommit_checkbox, + "If checked, uses message below to commit 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 + # Row 1: Commit Message Label commit_msg_label = ttk.Label(frame, text="Commit Message:") - commit_msg_label.grid(row=1, column=0, sticky="w", padx=5, pady=3) + commit_msg_label.grid(row=1, column=0, columnspan=2, sticky="w", padx=5) - self.commit_message_entry = ttk.Entry( + # Row 2: Commit Message Text Area + self.commit_message_text = scrolledtext.ScrolledText( frame, - textvariable=self.commit_message_var, - width=50, # Adjust width as needed - state=tk.DISABLED # State depends on repo readiness + height=7, + width=60, + wrap=tk.WORD, # Increased height + font=("Segoe UI", 9), + state=tk.DISABLED, # Start disabled + ) + self.commit_message_text.grid( + row=2, column=0, columnspan=2, sticky="nsew", padx=5, pady=(0, 5) + ) + self.create_tooltip( + self.commit_message_text, + "Enter commit message here for manual commit or autocommit.", ) - 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.") - # Manual Commit Button + # Row 3: Commit Button self.commit_button = ttk.Button( frame, - text="Commit Changes", - width=15, # Adjusted width - command=self.manual_commit_callback, # Connect to controller - state=tk.DISABLED # State depends on repo readiness + text="Commit Staged Changes", # Clarified label + command=self.commit_changes_callback, # Link to controller method + state=tk.DISABLED, + ) + self.commit_button.grid( + row=3, column=0, columnspan=2, sticky="e", padx=5, pady=5 + ) + self.create_tooltip( + self.commit_button, "Stage ALL changes and commit with the message above." ) - 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.") return frame + def _create_tags_tab(self): + """Creates the frame for the 'Tags' tab.""" + frame = ttk.Frame(self.notebook, padding=(10, 10)) + frame.columnconfigure(0, weight=1) # Listbox column expands + frame.rowconfigure(1, weight=1) # Listbox row expands - 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(1, weight=1) # Listbox column expands - frame.rowconfigure(2, weight=1) # Listbox row expands + # Row 0: Label + lbl = ttk.Label(frame, text="Existing Tags (Newest First):") + lbl.grid(row=0, column=0, sticky="w", padx=5, pady=(0, 2)) - # 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) + # Row 1: Listbox + Scrollbar Frame + list_frame = ttk.Frame(frame) + list_frame.grid(row=1, column=0, sticky="nsew", padx=(5, 0), pady=(0, 5)) + list_frame.rowconfigure(0, weight=1) + list_frame.columnconfigure(0, weight=1) - self.current_branch_display = ttk.Label( - 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? 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:") - # 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.rowconfigure(0, weight=1) - branch_list_frame.columnconfigure(0, weight=1) - - self.branch_listbox = tk.Listbox( - branch_list_frame, - height=5, # Initial height + self.tag_listbox = tk.Listbox( + list_frame, + height=10, exportselection=False, selectmode=tk.SINGLE, - font=("Consolas", 9) # Monospaced font for potential alignment + font=("Consolas", 9), + ) + self.tag_listbox.grid(row=0, column=0, sticky="nsew") + scrollbar = ttk.Scrollbar( + list_frame, orient=tk.VERTICAL, command=self.tag_listbox.yview + ) + scrollbar.grid(row=0, column=1, sticky="ns") + self.tag_listbox.config(yscrollcommand=scrollbar.set) + self.create_tooltip(self.tag_listbox, "Select tag to checkout.") + + # Row 1, Column 1: Vertical Button Frame + button_frame = ttk.Frame(frame) + button_frame.grid(row=1, column=1, sticky="ns", padx=(10, 5), pady=(0, 5)) + + button_width = 18 # Uniform width + + self.refresh_tags_button = ttk.Button( + button_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, 5)) + self.create_tooltip(self.refresh_tags_button, "Reload tag list.") + + self.create_tag_button = ttk.Button( + button_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=5) + self.create_tooltip( + self.create_tag_button, "Commit (if needed with msg) & create tag." + ) + + self.checkout_tag_button = ttk.Button( + button_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=(5, 0)) + self.create_tooltip(self.checkout_tag_button, "Switch to selected tag.") + + return frame + + def _create_branch_tab(self): + """Creates the frame for the 'Branches' tab.""" + frame = ttk.Frame(self.notebook, padding=(10, 10)) + frame.columnconfigure(0, weight=1) # Listbox expands + frame.rowconfigure(1, weight=1) # Listbox expands + + # Row 0: Label + lbl = ttk.Label(frame, text="Local Branches (* Current):") + lbl.grid(row=0, column=0, sticky="w", padx=5, pady=(0, 2)) + + # Row 1: Listbox + Scrollbar Frame + list_frame = ttk.Frame(frame) + list_frame.grid(row=1, column=0, sticky="nsew", padx=(5, 0), pady=(0, 5)) + list_frame.rowconfigure(0, weight=1) + list_frame.columnconfigure(0, weight=1) + + self.branch_listbox = tk.Listbox( + list_frame, height=10, exportselection=False, selectmode=tk.SINGLE ) self.branch_listbox.grid(row=0, column=0, sticky="nsew") - - branch_scrollbar = ttk.Scrollbar( - branch_list_frame, - orient=tk.VERTICAL, - command=self.branch_listbox.yview + scrollbar = ttk.Scrollbar( + 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).") + scrollbar.grid(row=0, column=1, sticky="ns") + self.branch_listbox.config(yscrollcommand=scrollbar.set) + self.create_tooltip(self.branch_listbox, "Select branch to checkout.") - # Row 2, Column 3: Vertical Button Frame for Branch Actions - branch_button_frame = ttk.Frame(frame) - # 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)) + # Row 1, Column 1: Vertical Button Frame + button_frame = ttk.Frame(frame) + button_frame.grid(row=1, column=1, sticky="ns", padx=(10, 5), pady=(0, 5)) - button_width_branch = 18 # Consistent width + button_width = 18 # Uniform width self.refresh_branches_button = ttk.Button( - branch_button_frame, text="Refresh List", width=button_width_branch, - command=self.refresh_branches_callback, state=tk.DISABLED + button_frame, + text="Refresh Branches", + width=button_width, + command=self.refresh_branches_callback, + state=tk.DISABLED, ) - self.refresh_branches_button.pack(side=tk.TOP, fill=tk.X, pady=(0, 3)) + self.refresh_branches_button.pack(side=tk.TOP, fill=tk.X, pady=(0, 5)) self.create_tooltip(self.refresh_branches_button, "Reload branch list.") self.create_branch_button = ttk.Button( - branch_button_frame, text="Create Branch...", width=button_width_branch, - command=self.create_branch_callback, state=tk.DISABLED + button_frame, + text="Create Branch...", + width=button_width, + command=self.create_branch_callback, + state=tk.DISABLED, ) - self.create_branch_button.pack(side=tk.TOP, fill=tk.X, pady=3) + self.create_branch_button.pack(side=tk.TOP, fill=tk.X, pady=5) self.create_tooltip(self.create_branch_button, "Create a new local branch.") - self.switch_branch_button = ttk.Button( - branch_button_frame, text="Switch to Selected", width=button_width_branch, - command=self.switch_branch_callback, state=tk.DISABLED + self.checkout_branch_button = ttk.Button( + button_frame, + text="Checkout Selected", + width=button_width, # Shortened text + command=self.checkout_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 the selected branch.") + self.checkout_branch_button.pack(side=tk.TOP, fill=tk.X, pady=5) + self.create_tooltip(self.checkout_branch_button, "Switch to selected branch.") - self.delete_branch_button = ttk.Button( - branch_button_frame, text="Delete Selected", width=button_width_branch, - command=self.delete_branch_callback, state=tk.DISABLED - ) - self.delete_branch_button.pack(side=tk.TOP, fill=tk.X, pady=(3, 0)) - self.create_tooltip(self.delete_branch_button, "Delete selected local branch.") - - return frame # Return the created frame - - - def _populate_tags_gitignore_tab(self, parent_tab_frame): - """Creates and places widgets for the Tags & Gitignore tab.""" - # 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 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( - 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( - 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 - - - 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( - 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( - 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( - 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).") - - # Delete Tag Button - self.delete_tag_button = ttk.Button( - 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 selected tag locally.") - - # Edit .gitignore Button - self.edit_gitignore_button = ttk.Button( - 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.") + # Placeholder for Delete Branch if added later + # self.delete_branch_button = ttk.Button(...) + # self.delete_branch_button.pack(...) return frame + def _create_history_tab(self): + """Creates the frame for the 'History' tab.""" + frame = ttk.Frame(self.notebook, padding=(10, 10)) + frame.rowconfigure(2, weight=1) # Text area expands + frame.columnconfigure(0, weight=1) # Text area expands + + # --- Filters --- (Row 0) + filter_frame = ttk.Frame(frame) + filter_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) + filter_frame.columnconfigure(1, weight=1) # Allow combobox to expand + + filter_label = ttk.Label(filter_frame, text="Filter by Branch:") + filter_label.pack(side=tk.LEFT, padx=(0, 5)) + + self.history_branch_filter_var = tk.StringVar() + self.history_branch_filter_combo = ttk.Combobox( + filter_frame, + textvariable=self.history_branch_filter_var, + state="readonly", + width=30, + ) + self.history_branch_filter_combo.pack( + side=tk.LEFT, expand=True, fill=tk.X, padx=5 + ) + # Apply filter when selection changes + self.history_branch_filter_combo.bind( + "<>", lambda e: self.refresh_history_callback() + ) + self.create_tooltip( + self.history_branch_filter_combo, + "Show history for selected branch (or all).", + ) + + self.refresh_history_button = ttk.Button( + filter_frame, + text="Refresh History", + command=self.refresh_history_callback, + state=tk.DISABLED, + ) + self.refresh_history_button.pack(side=tk.LEFT, padx=5) + self.create_tooltip(self.refresh_history_button, "Load commit history.") + + # --- History Display --- (Row 1, 2, 3) + history_label = ttk.Label(frame, text="Recent Commits:") + history_label.grid(row=1, column=0, sticky="w", padx=5, pady=(5, 0)) + + self.history_text = scrolledtext.ScrolledText( + frame, + height=15, + width=100, + font=("Consolas", 9), + wrap=tk.NONE, + state=tk.DISABLED, + ) + self.history_text.grid(row=2, column=0, sticky="nsew", padx=5, pady=(0, 5)) + + history_xscroll = ttk.Scrollbar( + frame, orient=tk.HORIZONTAL, command=self.history_text.xview + ) + history_xscroll.grid(row=3, column=0, sticky="ew", padx=5) + self.history_text.config(xscrollcommand=history_xscroll.set) + + 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 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 - ) - self.manual_backup_button.pack(side=tk.LEFT, padx=5, pady=5) - + """REMOVED - This frame is no longer used in the tabbed layout.""" + pass # Return None or simply don't call this method def _create_log_area(self): - """Creates the scrolled text area for logging output.""" - log_frame = ttk.Frame(self.master) # Attach to root window - # Pack at the very bottom, allow expansion - log_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True, - padx=10, pady=(0, 10)) # Padding only below + """Creates the application log area at the bottom.""" + log_frame = ttk.Frame(self, padding=(0, 5, 0, 0)) # Parent is MainFrame + log_frame.pack( + side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=0, pady=(5, 0) + ) # Pack below notebook + + log_label = ttk.Label(log_frame, text="Application Log:") + log_label.pack(side=tk.TOP, anchor=tk.W, padx=5) self.log_text = scrolledtext.ScrolledText( - log_frame, height=8, width=100, # Adjusted height - font=("Consolas", 9), wrap=tk.WORD, state=tk.DISABLED + log_frame, + height=8, + width=100, + font=("Consolas", 9), + wrap=tk.WORD, + state=tk.DISABLED, + ) + self.log_text.pack( + side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=5, pady=(0, 5) ) - self.log_text.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True) - def _initialize_profile_selection(self): - """Sets the initial value of the profile dropdown.""" - try: - from config_manager import DEFAULT_PROFILE - 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 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 - + """Sets the initial value of the profile dropdown.""" + try: + from config_manager import DEFAULT_PROFILE + except ImportError: + DEFAULT_PROFILE = "default" + if DEFAULT_PROFILE in self.initial_profile_sections: + self.profile_var.set(DEFAULT_PROFILE) + elif self.initial_profile_sections: + self.profile_var.set(self.initial_profile_sections[0]) # --- GUI Update Methods --- def toggle_backup_dir(self): - """Enables/disables backup directory widgets based on checkbox.""" - new_state = tk.NORMAL if self.autobackup_var.get() else tk.DISABLED - if hasattr(self, 'backup_dir_entry'): - self.backup_dir_entry.config(state=new_state) - if hasattr(self, 'backup_dir_button'): - self.backup_dir_button.config(state=new_state) - + """Toggles state of backup directory widgets.""" + state = tk.NORMAL if self.autobackup_var.get() else tk.DISABLED + if hasattr(self, "backup_dir_entry"): + self.backup_dir_entry.config(state=state) + if hasattr(self, "backup_dir_button"): + self.backup_dir_button.config(state=state) def browse_backup_dir(self): - """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) - + """Opens directory dialog for backup path.""" + 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) def update_svn_indicator(self, is_prepared): - """Updates only the indicator color and Prepare button state.""" + """Updates repo indicator color and Prepare button state.""" color = self.GREEN if is_prepared else self.RED 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'): + tooltip = "Prepared" if is_prepared else "Not prepared" + 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) - + self.update_tooltip(self.svn_status_indicator, tooltip) + if hasattr(self, "prepare_svn_button"): # Prepare button is in Repo tab + self.prepare_svn_button.config(state=state) def update_profile_dropdown(self, sections): - """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") # Triggers load - else: - self.profile_var.set(sections[0]) # Triggers load - else: - self.profile_var.set("") # Triggers load with empty + """Updates profile dropdown list and selection.""" + if hasattr(self, "profile_dropdown"): + current = self.profile_var.get() + self.profile_dropdown["values"] = sections + if sections: + if current in sections: + self.profile_var.set(current) + elif "default" in sections: + self.profile_var.set("default") + else: + self.profile_var.set(sections[0]) + else: + self.profile_var.set("") - - def update_tag_list(self, tags_with_subjects): - """Clears and repopulates tag listbox with name and subject.""" - if not hasattr(self, 'tag_listbox'): - # Log error if listbox doesn't exist when called - logging.error("Cannot update tag list: Listbox widget not found.") + def update_tag_list(self, tags_data): + """Updates tag listbox with (name, subject) tuples.""" + if not hasattr(self, "tag_listbox"): return try: - self.tag_listbox.delete(0, tk.END) # Clear list - if tags_with_subjects: - # Reset text color if it was previously greyed out - try: - 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: - # 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: - # Use tab separation for basic alignment - display = f"{name}\t({subject})" - self.tag_listbox.insert(tk.END, display) + self.tag_listbox.delete(0, tk.END) + if tags_data: + try: # Reset color + if self.tag_listbox.cget("fg") == "grey": + self.tag_listbox.config(fg="SystemWindowText") + except tk.TclError: + pass + for name, subject in tags_data: + self.tag_listbox.insert(tk.END, f"{name}\t({subject})") else: - # Show placeholder text if no tags - self.tag_listbox.insert(tk.END, "(No tags found)") - try: - self.tag_listbox.config(fg="grey") # Dim placeholder text - except tk.TclError: - pass # Ignore color setting errors - - except tk.TclError as e: - logging.error(f"TclError updating tag listbox: {e}") + self.tag_listbox.insert(tk.END, "(No tags found)") + try: + self.tag_listbox.config(fg="grey") + except tk.TclError: + pass except Exception as e: - logging.error(f"Error updating tag listbox: {e}", exc_info=True) - + logging.error(f"Error tags: {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'): + if hasattr(self, "tag_listbox"): indices = self.tag_listbox.curselection() - # Check if there is a selection (curselection returns tuple) if indices: - selected_index = indices[0] # Get the index - item = self.tag_listbox.get(selected_index) # Get text at index - # Ignore placeholder text + item = self.tag_listbox.get(indices[0]) if item != "(No tags found)": - # Extract name (text before the first tab) - tag_name = item.split('\t', 1)[0] - tag_name = tag_name.strip() # Remove any extra spaces - return tag_name # Return name or None + return item.split("\t", 1)[0].strip() + return None - - def update_branch_list(self, branches): - """Clears and repopulates the branch listbox.""" - if not hasattr(self, 'branch_listbox'): - logging.error("Branch listbox missing for update.") + def update_branch_list(self, branches, current_branch): + """Updates branch listbox, highlighting current.""" + if not hasattr(self, "branch_listbox"): return try: - current = self.current_branch_var.get() # Get displayed current branch - self.branch_listbox.delete(0, tk.END) # Clear list + self.branch_listbox.delete(0, tk.END) + sel_index = -1 if branches: - # Reset color if needed - try: - if self.branch_listbox.cget("fg") == "grey": - self.branch_listbox.config(fg='SystemWindowText') - except tk.TclError: pass - # Insert branches, highlight current - for branch in branches: - is_current = (branch == current) - # Add '*' prefix for current branch display - 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'} - ) + for i, branch in enumerate(branches): + prefix = "* " if branch == current_branch else " " + self.branch_listbox.insert(tk.END, f"{prefix}{branch}") + if branch == current_branch: + sel_index = i else: - # Show placeholder if no branches - self.branch_listbox.insert(tk.END, "(No local branches?)") - 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}") + self.branch_listbox.insert(tk.END, "(No local branches)") + # Select current branch if found + if sel_index >= 0: + self.branch_listbox.selection_set(sel_index) + self.branch_listbox.see(sel_index) except Exception as e: - logging.error(f"Error updating branches: {e}", exc_info=True) - + logging.error(f"Error branches: {e}", exc_info=True) def get_selected_branch(self): """Returns the name only of the selected branch.""" - branch_name = None - if hasattr(self, 'branch_listbox'): + 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 - branch_name = item.lstrip("* ").strip() - return branch_name # Return name or None + if item != "(No local branches)": + return item.lstrip("* ").strip() + return None + def get_commit_message(self): + """Gets commit message from ScrolledText widget.""" + if hasattr(self, "commit_message_text"): + return self.commit_message_text.get("1.0", tk.END).strip() + return "" - def set_current_branch_display(self, branch_name): - """Updates the label showing the current branch.""" - if hasattr(self, 'current_branch_var'): - # Set display text, handling None or empty string - display_text = branch_name if branch_name else "(DETACHED or N/A)" - self.current_branch_var.set(display_text) + def clear_commit_message(self): + """Clears the commit message ScrolledText widget.""" + if hasattr(self, "commit_message_text"): + # Check state before modifying - avoid error if disabled + if self.commit_message_text.cget("state") == tk.NORMAL: + self.commit_message_text.delete("1.0", tk.END) + else: # If disabled, enable, clear, disable + self.commit_message_text.config(state=tk.NORMAL) + self.commit_message_text.delete("1.0", tk.END) + self.commit_message_text.config(state=tk.DISABLED) + def update_history_display(self, log_lines): + """Updates the commit history text area.""" + if not hasattr(self, "history_text"): + return + try: + self.history_text.config(state=tk.NORMAL) + self.history_text.delete("1.0", tk.END) + if log_lines: + self.history_text.insert(tk.END, "\n".join(log_lines)) + else: + self.history_text.insert(tk.END, "(No history found)") + self.history_text.config(state=tk.DISABLED) + self.history_text.yview_moveto(0.0) # Scroll top + except Exception as e: + logging.error(f"Error history: {e}", exc_info=True) - # --- Dialog Wrappers --- + def update_history_branch_filter(self, branches, current_branch=None): + """Populates branch filter combobox in History tab.""" + if not hasattr(self, "history_branch_filter_combo"): + return + filter_options = ["-- All History --"] + branches + self.history_branch_filter_combo["values"] = filter_options + # Set default selection + if current_branch and current_branch in branches: + self.history_branch_filter_var.set(current_branch) + else: + self.history_branch_filter_var.set(filter_options[0]) + + # --- Dialog Wrappers (Unchanged) --- def ask_new_profile_name(self): - """Asks the user for a new profile name.""" - return simpledialog.askstring("Add Profile", "Enter new profile name:", - parent=self.master) + return simpledialog.askstring("Add Profile", "Enter name:", parent=self.master) def show_error(self, title, message): - """Displays an error message box.""" - messagebox.showerror(title, message, parent=self.master) + messagebox.showerror(title, message, parent=self.master) def show_info(self, title, message): - """Displays an information message box.""" - messagebox.showinfo(title, message, parent=self.master) + messagebox.showinfo(title, message, parent=self.master) def show_warning(self, title, message): - """Displays a warning message box.""" messagebox.showwarning(title, message, parent=self.master) def ask_yes_no(self, title, message): - """Displays a yes/no confirmation dialog.""" - return messagebox.askyesno(title, message, parent=self.master) + return messagebox.askyesno(title, message, parent=self.master) - - # --- Tooltip Helpers --- + # --- Tooltip Helpers (Unchanged) --- def create_tooltip(self, widget, text): - """Creates a tooltip for a given widget.""" - tooltip = Tooltip(widget, text) - # 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='+') + tt = Tooltip(widget, text) + widget.bind("", lambda e, t=tt: t.showtip(), add="+") + widget.bind("", lambda e, t=tt: t.hidetip(), add="+") + widget.bind("", lambda e, t=tt: t.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 - widget.unbind("") - widget.unbind("") - widget.unbind("") - # Re-create the tooltip with the new text - self.create_tooltip(widget, text) \ No newline at end of file + widget.unbind("") + widget.unbind("") + widget.unbind("") + self.create_tooltip(widget, text) diff --git a/profile_handler.py b/profile_handler.py index 79ac965..755202c 100644 --- a/profile_handler.py +++ b/profile_handler.py @@ -1,8 +1,10 @@ # profile_handler.py import logging + # Assuming ConfigManager handles its own constants now from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR + class ProfileHandler: """Handles loading, saving, adding, removing profiles via ConfigManager.""" @@ -50,15 +52,16 @@ class ProfileHandler: # Convert loaded string values back to appropriate types if needed # Example: Booleans - profile_data["autocommit"] = \ + profile_data["autocommit"] = ( str(profile_data.get("autocommit", "False")).lower() == "true" - profile_data["autobackup"] = \ + ) + profile_data["autobackup"] = ( str(profile_data.get("autobackup", "False")).lower() == "true" + ) self.logger.debug(f"Loaded data for '{profile_name}': {profile_data}") return profile_data - def save_profile_data(self, profile_name, profile_data): """ Saves the provided data dictionary to the specified profile name. @@ -90,21 +93,19 @@ class ProfileHandler: # Assume other types can be directly converted to string value_to_save = str(value) if value is not None else "" - self.config_manager.set_profile_option( - profile_name, key, value_to_save - ) + self.config_manager.set_profile_option(profile_name, key, value_to_save) # Persist changes to the configuration file self.config_manager.save_config() self.logger.info(f"Profile '{profile_name}' saved successfully.") return True except Exception as e: - self.logger.error(f"Error saving profile '{profile_name}': {e}", - exc_info=True) + self.logger.error( + f"Error saving profile '{profile_name}': {e}", exc_info=True + ) # Let the caller (main app) show the error message return False - def add_new_profile(self, profile_name): """ Adds a new profile with default settings. @@ -129,13 +130,13 @@ class ProfileHandler: defaults = self.config_manager._get_expected_keys_with_defaults() defaults["bundle_name"] = f"{profile_name}_repo.bundle" defaults["bundle_name_updated"] = f"{profile_name}_update.bundle" - defaults["svn_working_copy_path"] = "" # Start empty - defaults["usb_drive_path"] = "" # Start empty + defaults["svn_working_copy_path"] = "" # Start empty + defaults["usb_drive_path"] = "" # Start empty # Set all options for the new profile section # ConfigManager.set_profile_option creates the section if needed for key, value in defaults.items(): - self.config_manager.set_profile_option(profile_name, key, value) + self.config_manager.set_profile_option(profile_name, key, value) # Save the configuration file self.config_manager.save_config() @@ -143,11 +144,11 @@ class ProfileHandler: return True except Exception as e: # Log unexpected errors during profile addition - self.logger.error(f"Error adding profile '{profile_name}': {e}", - exc_info=True) + self.logger.error( + f"Error adding profile '{profile_name}': {e}", exc_info=True + ) return False - def remove_existing_profile(self, profile_name): """ Removes an existing profile (cannot remove the default profile). @@ -180,10 +181,13 @@ class ProfileHandler: return True else: # ConfigManager should log the reason for failure - self.logger.error(f"ConfigManager reported failure removing '{profile_name}'.") + self.logger.error( + f"ConfigManager reported failure removing '{profile_name}'." + ) return False except Exception as e: # Log unexpected errors during removal - self.logger.error(f"Error removing profile '{profile_name}': {e}", - exc_info=True) - return False \ No newline at end of file + self.logger.error( + f"Error removing profile '{profile_name}': {e}", exc_info=True + ) + return False