diff --git a/GitUtility.py b/GitUtility.py index 9e886e8..ca8b97c 100644 --- a/GitUtility.py +++ b/GitUtility.py @@ -1,11 +1,10 @@ # GitUtility.py import os -# import shutil # Not needed here anymore import datetime import tkinter as tk from tkinter import messagebox import logging -# import zipfile # Not needed here anymore +import zipfile # Import application modules from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR @@ -57,7 +56,7 @@ class GitSvnSyncApp: 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 # Stop initialization + return # --- Create GUI Main Frame --- try: @@ -82,6 +81,7 @@ class GitSvnSyncApp: 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, @@ -111,10 +111,10 @@ class GitSvnSyncApp: 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) + # Load settings (called automatically by trace on profile_var) else: self.logger.warning("No profile selected on startup.") - self._clear_and_disable_fields() + self._clear_and_disable_fields() # Set initial disabled state self.logger.info("Application started successfully.") @@ -128,19 +128,32 @@ class GitSvnSyncApp: 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 missing.") + 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.") + # Try showing error if main_frame exists, otherwise just log + if hasattr(self, 'main_frame'): + self.main_frame.show_error("Internal Error", + "SVN Path widget not found.") return None - svn_path_str = self.main_frame.svn_path_entry.get().strip() + 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 empty.") - self.main_frame.show_error("Input Error", "SVN Path empty.") + 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 SVN path: {abs_path}") - self.main_frame.show_error("Input Error", f"Invalid SVN path:\n{abs_path}") + self.logger.error( + f"{operation_name}: Invalid directory path: {abs_path}" + ) + self.main_frame.show_error( + "Input Error", + f"Invalid SVN path (not a directory):\n{abs_path}" + ) return None self.logger.debug(f"{operation_name}: Validated SVN path: {abs_path}") @@ -150,19 +163,32 @@ class GitSvnSyncApp: 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 missing.") + 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 not found.") + return None - usb_path_str = self.main_frame.usb_path_entry.get().strip() + 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 empty.") - self.main_frame.show_error("Input Error", "Bundle Target empty.") + self.logger.error(f"{operation_name}: Bundle Target Dir path empty.") + self.main_frame.show_error("Input Error", + "Bundle Target Directory cannot be empty.") return None abs_path = os.path.abspath(usb_path_str) if not os.path.isdir(abs_path): - self.logger.error(f"{operation_name}: Invalid Bundle Target: {abs_path}") - self.main_frame.show_error("Input Error", f"Invalid Bundle Target:\n{abs_path}") + self.logger.error( + f"{operation_name}: Invalid Bundle Target directory: {abs_path}" + ) + self.main_frame.show_error( + "Input Error", + f"Invalid Bundle Target path (not a directory):\n{abs_path}" + ) return None self.logger.debug(f"{operation_name}: Validated Bundle Target path: {abs_path}") @@ -179,10 +205,17 @@ class GitSvnSyncApp: # 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 '{profile_name}'.") + self._clear_and_disable_fields() + return - if profile_data and hasattr(self, 'main_frame'): + # Update GUI fields with loaded data if frame exists + if hasattr(self, 'main_frame'): mf = self.main_frame # Alias - # Update GUI fields + # Update Repository Frame widgets mf.svn_path_entry.delete(0, tk.END) mf.svn_path_entry.insert(0, profile_data.get("svn_working_copy_path", "")) mf.usb_path_entry.delete(0, tk.END) @@ -191,8 +224,10 @@ class GitSvnSyncApp: mf.bundle_name_entry.insert(0, profile_data.get("bundle_name", "")) mf.bundle_updated_name_entry.delete(0, tk.END) mf.bundle_updated_name_entry.insert(0, profile_data.get("bundle_name_updated", "")) + # Update Commit/Tag Frame widgets mf.autocommit_var.set(profile_data.get("autocommit", False)) # Bool mf.commit_message_var.set(profile_data.get("commit_message", "")) + # Update Backup Frame widgets mf.autobackup_var.set(profile_data.get("autobackup", False)) # Bool mf.backup_dir_var.set(profile_data.get("backup_dir", DEFAULT_BACKUP_DIR)) mf.backup_exclude_extensions_var.set( @@ -200,31 +235,27 @@ class GitSvnSyncApp: ) mf.toggle_backup_dir() # Update backup dir entry state - # Update status indicators and enable general buttons + # 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() + 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() # Refresh branches - self.update_current_branch_display() # Update current branch label + self.refresh_branch_list() + self.update_current_branch_display() # Update display label else: - # Clear lists if not ready + # Clear lists if repo not ready mf.update_tag_list([]) mf.update_branch_list([]) - mf.set_current_branch_display("") + mf.set_current_branch_display("") # Reset display label self.logger.info(f"Settings loaded successfully for '{profile_name}'.") - elif not profile_data: - # Profile loading failed - self.main_frame.show_error("Load Error", f"Could not load '{profile_name}'.") - self._clear_and_disable_fields() else: - self.logger.error("Cannot load settings: Main frame missing.") + self.logger.error("Cannot load settings: Main frame missing.") def ui_save_settings(self): @@ -233,41 +264,42 @@ class GitSvnSyncApp: profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Save Error", "No profile selected.") - return # Return False? No, just don't proceed. + return # Don't proceed # Gather data from GUI current_data = self._get_data_from_gui() - if not current_data: # Check if data gathering failed - self.main_frame.show_error("Internal Error", "Could not read GUI data.") - return + if current_data is None: # Check if reading GUI failed + self.main_frame.show_error("Internal Error", "Could not read GUI data.") + return # Delegate saving to ProfileHandler success = self.profile_handler.save_profile_data(profile, current_data) if success: - self.main_frame.show_info("Settings Saved", f"Settings saved for '{profile}'.") + # Give positive feedback + self.main_frame.show_info("Saved", f"Settings saved for '{profile}'.") else: - # Error message shown by handler or save method? Assume shown. - # self.main_frame.show_error("Save Error", f"Failed to save settings.") - pass # Error already shown likely + # Error message likely shown by handler/save method + self.logger.error(f"Saving settings failed for profile '{profile}'.") + # self.main_frame.show_error("Save Error", "Failed to save settings.") 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 # Return None or empty dict? None indicates failure better. + return None # Indicate failure mf = self.main_frame - # Read values from widgets/variables + # Read values from all relevant widgets/variables data = { "svn_working_copy_path": mf.svn_path_entry.get(), "usb_drive_path": mf.usb_path_entry.get(), "bundle_name": mf.bundle_name_entry.get(), "bundle_name_updated": mf.bundle_updated_name_entry.get(), - "autocommit": mf.autocommit_var.get(), # Boolean + "autocommit": mf.autocommit_var.get(), # Gets boolean "commit_message": mf.commit_message_var.get(), - "autobackup": mf.autobackup_var.get(), # Boolean + "autobackup": mf.autobackup_var.get(), # Gets boolean "backup_dir": mf.backup_dir_var.get(), "backup_exclude_extensions": mf.backup_exclude_extensions_var.get() } @@ -295,7 +327,7 @@ class GitSvnSyncApp: self.main_frame.profile_var.set(new_name) # Triggers load self.main_frame.show_info("Profile Added", f"Profile '{new_name}' created.") else: - # Handler logged reason (exists or error) + # Handler logged the reason (exists or error) self.main_frame.show_error("Error", f"Could not add profile '{new_name}'.") @@ -318,7 +350,7 @@ class GitSvnSyncApp: self.main_frame.update_profile_dropdown(sections) # Triggers load self.main_frame.show_info("Removed", f"Profile '{profile}' removed.") else: - # Handler logged reason + # Handler logged the reason self.main_frame.show_error("Error", f"Failed to remove '{profile}'.") else: self.logger.info("Profile removal cancelled.") @@ -352,39 +384,37 @@ class GitSvnSyncApp: f"Valid:{is_valid}, Ready:{is_ready}") if hasattr(self, 'main_frame'): - mf = self.main_frame - # Update indicator & Prepare button via GUI method + mf = self.main_frame # Alias + # Update indicator & Prepare button via MainFrame method mf.update_svn_indicator(is_ready) # Determine states for other widgets based on validity/readiness gitignore_state = tk.NORMAL if is_valid else tk.DISABLED - commit_tag_branch_state = tk.NORMAL if is_ready else tk.DISABLED + repo_ready_state = tk.NORMAL if is_ready else tk.DISABLED - # Apply states to relevant widgets - if hasattr(mf, 'edit_gitignore_button'): - mf.edit_gitignore_button.config(state=gitignore_state) - if hasattr(mf, 'commit_message_entry'): - mf.commit_message_entry.config(state=commit_tag_branch_state) - if hasattr(mf, 'autocommit_checkbox'): - mf.autocommit_checkbox.config(state=commit_tag_branch_state) - if hasattr(mf, 'commit_button'): # Manual commit button - mf.commit_button.config(state=commit_tag_branch_state) - # Branch widgets - if hasattr(mf, 'refresh_branches_button'): - mf.refresh_branches_button.config(state=commit_tag_branch_state) - if hasattr(mf, 'create_branch_button'): - mf.create_branch_button.config(state=commit_tag_branch_state) - if hasattr(mf, 'switch_branch_button'): - mf.switch_branch_button.config(state=commit_tag_branch_state) - if hasattr(mf, 'delete_branch_button'): - mf.delete_branch_button.config(state=commit_tag_branch_state) - # Tag widgets - if hasattr(mf, 'refresh_tags_button'): - mf.refresh_tags_button.config(state=commit_tag_branch_state) - if hasattr(mf, 'create_tag_button'): - mf.create_tag_button.config(state=commit_tag_branch_state) - if hasattr(mf, 'checkout_tag_button'): - mf.checkout_tag_button.config(state=commit_tag_branch_state) + # 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: @@ -402,7 +432,8 @@ class GitSvnSyncApp: try: # Create and run the modal editor window editor = GitignoreEditorWindow(self.master, gitignore_path, self.logger) - self.logger.debug("Gitignore editor finished.") # After window closes + # Execution blocks here until editor is closed + self.logger.debug("Gitignore editor finished.") except Exception as e: self.logger.exception(f"Error opening .gitignore editor: {e}") self.main_frame.show_error("Editor Error", @@ -419,7 +450,7 @@ class GitSvnSyncApp: # Save settings before action if not self.ui_save_settings(): - self.logger.warning("Prepare SVN: Failed to save settings first.") + self.logger.warning("Prepare SVN: Failed save settings first.") # Ask user? # Delegate execution to ActionHandler @@ -429,15 +460,13 @@ class GitSvnSyncApp: # Update GUI state after successful preparation self.update_svn_status_indicator(svn_path) except ValueError as e: # Catch specific "already prepared" error - self.logger.info(f"Prepare Repo info: {e}") 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.logger.error(f"Error preparing repository: {e}") self.main_frame.show_error("Error", f"Failed prepare:\n{e}") - self.update_svn_status_indicator(svn_path) # Update state + self.update_svn_status_indicator(svn_path) # Update state after failure except Exception as e: - self.logger.exception(f"Unexpected error during preparation: {e}") + self.logger.exception(f"Unexpected prepare error: {e}") self.main_frame.show_error("Error", f"Unexpected error:\n{e}") self.update_svn_status_indicator(svn_path) @@ -462,20 +491,20 @@ class GitSvnSyncApp: # Ensure .bundle extension if not bundle_name.lower().endswith(".bundle"): bundle_name += ".bundle" - self.main_frame.bundle_name_entry.delete(0, tk.END) - self.main_frame.bundle_name_entry.insert(0, bundle_name) + mf = self.main_frame + 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 - current_settings = self._get_data_from_gui() - if not current_settings: return # Failed to get GUI data - - backup_needed = current_settings.get("autobackup", False) - commit_needed = current_settings.get("autocommit", False) - commit_msg = current_settings.get("commit_message", "") - backup_dir = current_settings.get("backup_dir", "") + settings = self._get_data_from_gui() + if not settings: return + backup_needed = settings.get("autobackup", False) + commit_needed = settings.get("autocommit", False) + commit_msg = settings.get("commit_message", "") + backup_dir = settings.get("backup_dir", "") try: - # Parse exclusions needed for backup step within action handler + # 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)) @@ -483,31 +512,25 @@ class GitSvnSyncApp: # Save settings before action if not self.ui_save_settings(): - self.logger.warning("Create Bundle: Failed to save settings first.") + 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=svn_path, - bundle_full_path=bundle_full_path, - profile_name=profile, - autobackup_enabled=backup_needed, - backup_base_dir=backup_dir, - autocommit_enabled=commit_needed, - commit_message=commit_msg, - excluded_extensions=excluded_ext, - excluded_dirs=excluded_dir + svn_path, bundle_full_path, profile, backup_needed, backup_dir, + commit_needed, commit_msg, excluded_ext, excluded_dir ) # Show feedback based on result if created_path: self.main_frame.show_info("Success", f"Bundle created:\n{created_path}") else: + # Non-fatal issue (e.g., empty bundle) self.main_frame.show_warning("Info", "Bundle empty or not created.") except Exception as e: # Handle errors raised by action_handler self.logger.error(f"Bundle process error: {e}", exc_info=True) - self.main_frame.show_error("Error", f"Failed:\n{e}") + self.main_frame.show_error("Error", f"Failed create bundle:\n{e}") def ui_fetch_bundle(self): @@ -533,11 +556,10 @@ class GitSvnSyncApp: return # Get settings needed by action handler - current_settings = self._get_data_from_gui() - if not current_settings: return - - backup_needed = current_settings.get("autobackup", False) - backup_dir = current_settings.get("backup_dir", "") + settings = self._get_data_from_gui() + if not settings: return + backup_needed = settings.get("autobackup", False) + backup_dir = settings.get("backup_dir", "") try: excluded_ext, excluded_dir = self._parse_exclusions(profile) except ValueError as e: @@ -546,24 +568,19 @@ class GitSvnSyncApp: # Save settings before action if not self.ui_save_settings(): - self.logger.warning("Fetch Bundle: Failed to save settings first.") + self.logger.warning("Fetch Bundle: Could not save settings.") # Ask user? # Delegate execution to ActionHandler try: self.action_handler.execute_fetch_bundle( - svn_path=svn_path, - bundle_full_path=bundle_full_path, - profile_name=profile, - autobackup_enabled=backup_needed, - backup_base_dir=backup_dir, - excluded_extensions=excluded_ext, - excluded_dirs=excluded_dir + 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 UI state after potential changes + # Refresh state after fetch completes successfully self.refresh_branch_list() self.update_current_branch_display() self.refresh_tag_list() @@ -580,10 +597,7 @@ class GitSvnSyncApp: except Exception as e: # Handle other errors (backup, unexpected) self.logger.error(f"Fetch process error: {e}", exc_info=True) - if isinstance(e, IOError) and "backup" in str(e).lower(): - self.main_frame.show_error("Backup Error", f"Backup failed:\n{e}") - else: - self.main_frame.show_error("Error", f"Fetch failed:\n{e}") + self.main_frame.show_error("Error", f"Fetch failed:\n{e}") def ui_manual_backup(self): @@ -604,7 +618,7 @@ class GitSvnSyncApp: # Check/create backup dir if not os.path.isdir(backup_dir): - create_q = f"Backup directory does not exist:\n{backup_dir}\n\nCreate it?" + 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) @@ -622,11 +636,10 @@ class GitSvnSyncApp: self.main_frame.show_error("Config Error", str(e)) return - # Save settings first (especially backup dir/exclusions) + # Save settings first (important for exclusions and backup dir) if not self.ui_save_settings(): - if not self.main_frame.ask_yes_no( - "Warning", "Could not save settings.\nContinue backup anyway?" - ): + 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 @@ -640,10 +653,7 @@ class GitSvnSyncApp: self.main_frame.show_info( "Backup Complete", f"Backup created:\n{backup_path}" ) - else: - # Should not happen if exceptions are raised correctly - self.main_frame.show_error("Backup Error", - "Backup failed (unknown reason).") + # 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) @@ -651,33 +661,32 @@ class GitSvnSyncApp: def ui_manual_commit(self): - """Callback for the 'Commit' button.""" + """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 GUI + # 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.") + self.main_frame.show_error("Commit Error", "Commit message cannot be empty.") return - # Save settings first? Optional. + # Save settings first? Optional, but saves the message if typed. if not self.ui_save_settings(): self.logger.warning("Manual Commit: Could not save settings.") - # Ask user? + # Ask user if they want to continue? - # Delegate execution to ActionHandler + # Delegate commit execution to ActionHandler try: - commit_made = self.action_handler.execute_manual_commit( - svn_path, commit_msg - ) + commit_made = self.action_handler.execute_manual_commit(svn_path, commit_msg) if commit_made: self.main_frame.show_info("Success", "Changes committed.") - # Optionally clear message field + # Optionally clear message field after successful commit # self.main_frame.commit_message_var.set("") else: - self.main_frame.show_info("Info", "No changes to commit.") + # git_commit already logged "nothing to commit" + self.main_frame.show_info("Info", "No changes were detected to commit.") except (GitCommandError, ValueError) as e: self.logger.error(f"Manual commit failed: {e}") @@ -692,17 +701,17 @@ class GitSvnSyncApp: """Refreshes tag list in GUI.""" self.logger.info("--- Action: Refresh Tag List ---") svn_path = self._get_and_validate_svn_path("Refresh Tags") - if not svn_path: - if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([]) + # 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 - # Check repo readiness - if not os.path.exists(os.path.join(svn_path, ".git")): - self.logger.warning("Refresh Tags: Repo not prepared.") - if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([]) - return - # Fetch and update GUI + try: - tags_data = self.git_commands.list_tags(svn_path) # List of (name, subject) + # Get tag data (list of tuples) from GitCommands + tags_data = self.git_commands.list_tags(svn_path) + # Update the GUI listbox if hasattr(self, 'main_frame'): self.main_frame.update_tag_list(tags_data) self.logger.info(f"Tag list updated ({len(tags_data)} tags).") @@ -719,34 +728,33 @@ class GitSvnSyncApp: svn_path = self._get_and_validate_svn_path("Create Tag") if not svn_path: return profile = self.main_frame.profile_var.get() - if not profile: self.main_frame.show_error("Error", "No profile."); return + if not profile: + self.main_frame.show_error("Error", "No profile selected."); return - # Get commit message from GUI (needed by action handler for pre-commit) + # Get commit message from GUI (for potential pre-commit) commit_msg = self.main_frame.commit_message_var.get().strip() - # Save settings before action + # 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 --- + # Open Dialog first to get Tag Name and Tag Message self.logger.debug("Opening create tag dialog...") dialog = CreateTagDialog(self.master) tag_info = dialog.result # Returns (tag_name, tag_message) or None - if not tag_info: self.logger.info("Tag creation cancelled 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 --- + # Delegate Execution (including potential pre-commit) to ActionHandler try: 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 + if success: 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 @@ -782,7 +790,7 @@ class GitSvnSyncApp: # Save settings before action? Optional. if not self.ui_save_settings(): - self.logger.warning("Checkout Tag: Could not save profile settings.") + self.logger.warning("Checkout Tag: Could not save settings.") # Delegate execution to ActionHandler try: @@ -790,29 +798,64 @@ class GitSvnSyncApp: if success: self.main_frame.show_info("Success", f"Checked out tag '{selected_tag}'.\n\nNOTE: In 'detached HEAD'.") - # Update branch display after checkout + # Update display after successful checkout self.update_current_branch_display() except ValueError as e: # Catch specific errors like "uncommitted changes" - self.logger.error(f"Checkout blocked: {e}") self.main_frame.show_error("Checkout Blocked", str(e)) except GitCommandError as e: # Catch Git command errors - self.logger.error(f"Failed checkout tag '{selected_tag}': {e}") - self.main_frame.show_error("Checkout Error", f"Could not checkout:\n{e}") + 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 + msg = f"Delete tag '{selected_tag}' permanently?\nCannot be easily undone." + if not self.main_frame.ask_yes_no("Confirm Delete Tag", msg): + self.logger.info("Tag deletion cancelled.") + return + + self.logger.info(f"Attempting delete tag: {selected_tag}") + # Delegate execution to ActionHandler + try: + success = self.action_handler.execute_delete_tag(svn_path, selected_tag) + if success: + self.main_frame.show_info("Success", f"Tag '{selected_tag}' deleted.") + self.refresh_tag_list() # Update list + # else: action_handler raises error on failure + except (GitCommandError, ValueError) as e: + # Handle known errors (not found, invalid name format?) + self.main_frame.show_error("Error", f"Could not delete tag:\n{e}") + except Exception as e: + # Handle unexpected errors + self.logger.exception(f"Unexpected error deleting tag: {e}") + self.main_frame.show_error("Error", f"Unexpected error:\n{e}") + + # --- Branch Management Callbacks --- def refresh_branch_list(self): """Refreshes the branch list in the GUI.""" self.logger.info("--- Action: Refresh Branch List ---") svn_path = self._get_and_validate_svn_path("Refresh Branches") - if not svn_path or not os.path.exists(os.path.join(svn_path, ".git")): + # 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 # Repo not ready or path invalid + return + + # Fetch and update GUI try: - # Assuming git_commands has list_branches method branches = self.git_commands.list_branches(svn_path) if hasattr(self, 'main_frame'): self.main_frame.update_branch_list(branches) @@ -828,26 +871,28 @@ class GitSvnSyncApp: """Gets the current branch and updates the display label.""" self.logger.debug("Updating current branch display...") svn_path = self._get_and_validate_svn_path("Update Branch Display") - current_branch = "" # Default value + current_branch_name = "" # Default - # Only attempt if path is valid and repo is prepared + # Only query git if repo is ready if svn_path and os.path.exists(os.path.join(svn_path, ".git")): try: - # Assuming git_commands has get_current_branch method branch_name = self.git_commands.get_current_branch(svn_path) - # If method returns None or empty, it might be detached or error - if branch_name: - current_branch = branch_name - else: - # Could try git status or other checks to confirm detached HEAD - current_branch = "(DETACHED or Error)" # More specific default + # Handle return values from get_current_branch + if branch_name == "(DETACHED HEAD)": + current_branch_name = branch_name + elif branch_name == "": + current_branch_name = "" + elif branch_name: + current_branch_name = branch_name + else: # Should not happen if logic in get_current_branch is correct + current_branch_name = "" except Exception as e: self.logger.error(f"Failed to get current branch: {e}") - current_branch = "" + current_branch_name = "" - # Update the GUI label + # Update the GUI label via MainFrame method if hasattr(self, 'main_frame'): - self.main_frame.set_current_branch_display(current_branch) + self.main_frame.set_current_branch_display(current_branch_name) def ui_create_branch(self): @@ -856,26 +901,29 @@ class GitSvnSyncApp: svn_path = self._get_and_validate_svn_path("Create Branch") if not svn_path: return - # Use custom dialog to get branch name + # 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 to create branch: '{new_branch_name}'") + self.logger.info(f"Attempting create branch: '{new_branch_name}'") # Save settings? Optional. - # Delegate execution + # Delegate execution to ActionHandler try: - # Assuming ActionHandler has execute_create_branch - success = self.action_handler.execute_create_branch(svn_path, new_branch_name) + # TODO: Add start_point logic if needed later + 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 - # Maybe ask user if they want to switch? - # if self.main_frame.ask_yes_no("Switch Branch?", f"Switch to new branch '{new_branch_name}'?"): - # self.ui_switch_branch(new_branch_name) # Need direct switch method - # else: ActionHandler should raise specific error if exists etc. + # Ask user if they want to switch? + # switch_q = f"Switch to new branch '{new_branch_name}'?" + # if self.main_frame.ask_yes_no("Switch Branch?", switch_q): + # self.ui_switch_branch(new_branch_name) # Needs method adjustment + except (GitCommandError, ValueError) as e: self.main_frame.show_error("Error", f"Could not create branch:\n{e}") except Exception as e: @@ -893,6 +941,7 @@ class GitSvnSyncApp: if not selected_branch: self.main_frame.show_error("Error", "Select a branch."); return + # Avoid 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}'.") @@ -901,15 +950,17 @@ class GitSvnSyncApp: self.logger.info(f"Attempting switch to branch: {selected_branch}") # Save settings? Optional. - # Delegate execution (ActionHandler should check for uncommitted changes) + # Delegate execution (ActionHandler checks for changes) try: - # Assuming ActionHandler has execute_switch_branch - success = self.action_handler.execute_switch_branch(svn_path, selected_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}'.") - self.update_current_branch_display() # Update label - self.refresh_branch_list() # Update highlight in list - # else: Handler should raise error + # Update UI after successful switch + self.update_current_branch_display() + self.refresh_branch_list() # Update highlight + # 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 @@ -929,18 +980,15 @@ class GitSvnSyncApp: if not selected_branch: self.main_frame.show_error("Error", "Select a branch."); return - # Prevent deleting current branch + # 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 the current branch.") - return - # Prevent deleting common main branches + 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}' branch.") - return + self.main_frame.show_error("Error", f"Cannot delete '{selected_branch}'."); return # Confirmation - msg = f"Delete local branch '{selected_branch}'?\nThis cannot be undone easily!" + 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 @@ -949,27 +997,28 @@ class GitSvnSyncApp: # Delegate execution try: - # Assuming ActionHandler has execute_delete_branch - success = self.action_handler.execute_delete_branch(svn_path, selected_branch) + success = self.action_handler.execute_delete_branch( + svn_path, selected_branch, force=False # Attempt safe delete first + ) if success: self.main_frame.show_info("Success", f"Branch '{selected_branch}' deleted.") self.refresh_branch_list() # Update list - # else: Handler should raise error (e.g., not merged, requires force?) + # else: Handler raises error except GitCommandError as e: - # Handle specific errors, e.g., branch not fully merged (-d fails) + # 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 + # 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: Should raise error if force delete fails + # 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}") @@ -985,86 +1034,123 @@ class GitSvnSyncApp: # --- GUI State Utilities --- def _clear_and_disable_fields(self): - """Clears fields and disables most buttons.""" + """Clears relevant GUI fields and disables most buttons.""" if hasattr(self, 'main_frame'): mf = self.main_frame + # Clear Repo frame 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([]) mf.update_branch_list([]) mf.set_current_branch_display("") - # Reset indicator and dependent widgets - self.update_svn_status_indicator("") - # Disable general buttons + # Reset indicator and dependent buttons + self.update_svn_status_indicator("") # Disables state-dependent + # Disable general action buttons self._disable_general_buttons() - self.logger.debug("GUI fields cleared/reset.") + 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'): - names = ['create_bundle_button', 'fetch_bundle_button', - 'manual_backup_button', 'save_settings_button'] - for name in names: - widget = getattr(self.main_frame, name, None) - if widget: widget.config(state=tk.DISABLED) + # List of general button attribute names + button_names = [ + 'create_bundle_button', 'fetch_bundle_button', + 'manual_backup_button', 'save_settings_button' + ] + # Iterate and disable if the button exists + for name in button_names: + button = getattr(self.main_frame, name, None) + if button: + button.config(state=tk.DISABLED) def _enable_function_buttons(self): - """Enables general buttons. State buttons rely on status update.""" + """ + Enables general action buttons. State-dependent buttons rely on + update_svn_status_indicator. + """ if hasattr(self, 'main_frame'): - state = tk.NORMAL - names = ['create_bundle_button', 'fetch_bundle_button', - 'manual_backup_button', 'save_settings_button'] - for name in names: - widget = getattr(self.main_frame, name, None) - if widget: widget.config(state=state) - # Ensure state-dependent buttons reflect current status - self.update_svn_status_indicator(self.main_frame.svn_path_entry.get()) + general_state = tk.NORMAL + # List of general 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 handles Prepare, EditGitignore, Commit/Tag/Branch widgets + current_svn_path = "" + if hasattr(self.main_frame, 'svn_path_entry'): + current_svn_path = self.main_frame.svn_path_entry.get() + self.update_svn_status_indicator(current_svn_path) self.logger.debug("General buttons enabled. State buttons updated.") def show_fatal_error(self, message): """Shows a fatal error message.""" try: - parent = self.master if hasattr(self, 'master') and \ - self.master and self.master.winfo_exists() else None + # 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: print(f"FATAL ERROR: {message}") - except Exception as e: print(f"FATAL ERROR (+GUI error {e}): {message}") + except tk.TclError: + # Fallback if GUI is not ready or fails + print(f"FATAL ERROR: {message}") + except Exception as e: + # Log error showing the message box itself + print(f"FATAL ERROR (and GUI error: {e}): {message}") # --- Application Entry Point --- def main(): """Main function: Creates Tkinter root and runs the application.""" root = tk.Tk() - # Adjust min size for the new layout (may need further tweaking) - root.minsize(750, 800) # Increased height significantly - app = None + # Adjust min size for the new layout with tabs + # May need adjustment based on final widget sizes + root.minsize(750, 650) # Adjusted height back down slightly + app = None # Initialize app variable try: app = GitSvnSyncApp(root) # Start main loop only if initialization likely succeeded if hasattr(app, 'main_frame') and app.main_frame: root.mainloop() else: - print("App init failed before GUI setup.") - if root and root.winfo_exists(): root.destroy() + # Initialization failed before GUI setup could complete + print("Application initialization failed, exiting.") + # Ensure window closes if init failed but window was created + if root and root.winfo_exists(): + root.destroy() except Exception as e: - logging.exception("Fatal error during startup/mainloop.") + # Catch-all for unexpected errors during startup or main loop + logging.exception("Fatal error during application startup or main loop.") + # Try showing message box, fallback to print try: - parent = 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}):\n{e}") + parent_window = root if root and root.winfo_exists() else None + messagebox.showerror("Fatal Error", + f"Application failed unexpectedly:\n{e}", + parent=parent_window) + except Exception as msg_e: + print(f"FATAL ERROR (GUI error: {msg_e}): App failed:\n{e}") finally: + # Log application exit regardless of success or failure logging.info("Application exiting.") if __name__ == "__main__": - # Setup basic logging immediately - log_format = "%(asctime)s - %(levelname)s - [%(module)s:%(funcName)s] - %(message)s" - logging.basicConfig(level=logging.INFO, format=log_format) + # Setup basic logging immediately at startup + # This ensures logs are captured even if setup_logger fails later + log_format = "%(asctime)s - %(levelname)s - [%(module)s:%(funcName)s:%(lineno)d] - %(message)s" + logging.basicConfig(level=logging.INFO, format=log_format) # Consider level=logging.DEBUG for more detail main() \ No newline at end of file diff --git a/action_handler.py b/action_handler.py index d83d661..296991f 100644 --- a/action_handler.py +++ b/action_handler.py @@ -4,7 +4,7 @@ import os # Import dependencies from git_commands import GitCommands, GitCommandError -from backup_handler import BackupHandler # To perform backups +from backup_handler import BackupHandler class ActionHandler: """Handles the execution logic for core application actions.""" @@ -20,44 +20,47 @@ class ActionHandler: """ self.logger = logger self.git_commands = git_commands - self.backup_handler = backup_handler # Store backup handler instance - + self.backup_handler = backup_handler def _perform_backup_if_enabled(self, svn_path, profile_name, autobackup_enabled, backup_base_dir, excluded_extensions, excluded_dirs): """ - Performs backup if enabled. + Performs backup if enabled. Raises IOError on backup failure. Args: svn_path (str): Path to the repository. profile_name (str): Name of the profile (for backup naming). autobackup_enabled (bool): Flag from settings. backup_base_dir (str): Destination directory for backups. - excluded_extensions (set): Set of file extensions to exclude. - excluded_dirs (set): Set of directory names to exclude. + excluded_extensions (set): File extensions to exclude. + excluded_dirs (set): Directory names to exclude. - Returns: - bool: True if backup succeeded or was not needed, False on failure. + Raises: + IOError: If the backup process fails. """ if not autobackup_enabled: self.logger.debug("Autobackup disabled, skipping backup.") - return True # Not an error, just skipped + return # Backup not needed, proceed self.logger.info("Autobackup enabled. Starting backup...") try: - self.backup_handler.create_zip_backup( - svn_path, backup_base_dir, profile_name, - excluded_extensions, excluded_dirs + # Delegate backup creation to the BackupHandler instance + backup_path = self.backup_handler.create_zip_backup( + svn_path, + backup_base_dir, + profile_name, + excluded_extensions, + excluded_dirs ) - self.logger.info("Backup completed successfully.") - return True # Indicate backup success + # Log success if backup handler doesn't raise an error + self.logger.info(f"Backup completed successfully: {backup_path}") + # No explicit return value needed on success, failure indicated by exception + except Exception as backup_e: - # Log error and indicate backup failure + # Log error and re-raise as IOError to signal critical failure self.logger.error(f"Backup failed: {backup_e}", exc_info=True) - # Let the caller decide how to handle backup failure (e.g., show error) - # For now, just return False - return False + raise IOError(f"Autobackup failed: {backup_e}") from backup_e def execute_prepare_repo(self, svn_path): @@ -68,17 +71,17 @@ class ActionHandler: svn_path (str): Validated path to the repository. Returns: - bool: True on success. + bool: True on success. (Always True if no exception raised) Raises: ValueError: If repository is already prepared. GitCommandError/IOError/Exception: If preparation fails. """ self.logger.info(f"Executing preparation for: {svn_path}") - # Check if already prepared first + # Check if already prepared first to provide specific feedback git_dir = os.path.join(svn_path, ".git") if os.path.exists(git_dir): self.logger.warning("Repository is already prepared.") - # Raise specific error that UI can interpret + # Raise ValueError to signal this specific status to the UI layer raise ValueError("Repository is already prepared.") # Attempt preparation using GitCommands @@ -87,8 +90,8 @@ class ActionHandler: self.logger.info("Repository prepared successfully.") return True except (GitCommandError, ValueError, IOError) as e: - # Log and re-raise known errors - self.logger.error(f"Failed to prepare repository: {e}") + # Log and re-raise known errors for the UI layer to handle + self.logger.error(f"Failed to prepare repository: {e}", exc_info=True) raise except Exception as e: # Log and re-raise unexpected errors @@ -117,44 +120,45 @@ class ActionHandler: Returns: str or None: Path to created bundle on success, None if empty/not created. Raises: - Exception: Relays exceptions from backup, commit, or bundle creation. + IOError: If backup fails. + GitCommandError/ValueError/Exception: If commit or bundle creation fails. """ # --- Backup Step --- - if autobackup_enabled: - backup_success = self._perform_backup_if_enabled( - svn_path, profile_name, True, backup_base_dir, - excluded_extensions, excluded_dirs - ) - if not backup_success: - # Raise specific error if backup fails? Or just log and continue? - # For now, let's raise an error to stop the process. - raise IOError("Autobackup failed. Bundle creation aborted.") + # _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 + ) # --- Autocommit Step --- if autocommit_enabled: self.logger.info("Autocommit before bundle is enabled.") try: + # Check for changes before attempting commit has_changes = self.git_commands.git_status_has_changes(svn_path) 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}'") - # Perform commit (logs success/nothing internally) + # Perform commit (raises error on failure) self.git_commands.git_commit(svn_path, commit_msg_to_use) + # Log based on return value? git_commit already logs detail. + self.logger.info("Autocommit attempt finished.") else: self.logger.info("No changes detected for autocommit.") except Exception as commit_e: - # Let caller handle commit errors + # Log and re-raise commit error to stop the process self.logger.error(f"Autocommit failed: {commit_e}", exc_info=True) - raise commit_e # Re-raise to abort bundle creation + raise commit_e # --- Create Bundle Step --- self.logger.info(f"Creating bundle file: {bundle_full_path}") try: - # Execute command via GitCommands + # Execute command (raises error on failure) self.git_commands.create_git_bundle(svn_path, bundle_full_path) - # Check result after command execution + # Check result: file exists and is not empty bundle_exists = os.path.exists(bundle_full_path) bundle_not_empty = bundle_exists and os.path.getsize(bundle_full_path) > 0 if bundle_exists and bundle_not_empty: @@ -164,12 +168,14 @@ class ActionHandler: # 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: - try: os.remove(bundle_full_path) # Clean up - except OSError: pass + # Clean up empty file + try: + os.remove(bundle_full_path) + except OSError: + self.logger.warning(f"Could not remove empty bundle file.") return None # Indicate non-fatal issue (empty bundle) - except Exception as bundle_e: - # Let caller handle bundle creation errors + # Log and re-raise bundle creation errors self.logger.error(f"Bundle creation failed: {bundle_e}", exc_info=True) raise bundle_e @@ -183,27 +189,25 @@ class ActionHandler: Args: See execute_create_bundle args, excluding commit related ones. Raises: - Exception: Relays exceptions from backup or fetch/merge operations. + IOError: If backup fails. + GitCommandError/Exception: If fetch or merge fails (incl. conflicts). """ # --- Backup Step --- - if autobackup_enabled: - backup_success = self._perform_backup_if_enabled( - svn_path, profile_name, True, backup_base_dir, - excluded_extensions, excluded_dirs - ) - if not backup_success: - raise IOError("Autobackup failed. Fetch operation aborted.") + # Raises IOError on failure + self._perform_backup_if_enabled( + svn_path, profile_name, autobackup_enabled, backup_base_dir, + excluded_extensions, excluded_dirs + ) # --- Fetch and Merge Step --- self.logger.info(f"Fetching into '{svn_path}' from: {bundle_full_path}") try: - # Execute fetch/merge via GitCommands - # This method might raise GitCommandError for conflicts + # Delegate to GitCommands; it raises GitCommandError on conflict/failure self.git_commands.fetch_from_git_bundle(svn_path, bundle_full_path) - self.logger.info("Fetch/merge process completed.") - # Return True or some status? For now, rely on exceptions for errors. + self.logger.info("Fetch/merge process completed successfully.") + # No return value needed, success indicated by no exception except Exception as fetch_e: - # Let caller handle fetch/merge errors (incl. conflicts) + # Log and re-raise any error from fetch/merge self.logger.error(f"Fetch/merge failed: {fetch_e}", exc_info=True) raise fetch_e @@ -214,45 +218,46 @@ class ActionHandler: Args: svn_path (str): Validated path to the repository. - commit_message (str): The commit message (checked not empty by caller). + commit_message (str): The commit message (must not be empty). Returns: - bool: True if a commit was made, False if no changes were committed. + bool: True if commit made, False if no changes. Raises: - GitCommandError: If the commit command fails. - Exception: For other unexpected errors. + ValueError: If commit_message is empty. + GitCommandError/Exception: If commit command fails. """ if not commit_message: - # This validation should ideally happen before calling this method - self.logger.error("Manual commit attempted with empty message.") + # Should be validated by caller (UI layer) + 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: - # git_commit handles staging and committing + # git_commit handles staging and commit attempt + # It returns True/False and raises GitCommandError on failure commit_made = self.git_commands.git_commit(svn_path, commit_message) - if commit_made: - self.logger.info("Manual commit successful.") - else: - self.logger.info("Manual commit: Nothing to commit.") - return commit_made # Return status + return commit_made except Exception as e: + # Catch and re-raise errors from git_commit self.logger.error(f"Manual commit failed: {e}", exc_info=True) - raise # Re-raise for caller to handle + 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. - Args: See execute_manual_commit and add tag_name, tag_message. + Args: + svn_path (str): Validated path to repository. + commit_message (str): Message for pre-tag commit (if needed). Can be empty. + tag_name (str): Name for the new tag. + tag_message (str): Annotation message for the new tag. Returns: bool: True on successful tag creation. Raises: - ValueError: If validation fails (e.g., changes exist but no commit message). - GitCommandError: If commit or tag command fails. - Exception: For unexpected errors. + ValueError: If validation fails (e.g., changes exist, no commit message). + GitCommandError/Exception: If commit or tag command fails. """ self.logger.info(f"Executing create tag '{tag_name}' for: {svn_path}") @@ -265,31 +270,34 @@ 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 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 on success/no changes self.git_commands.git_commit(svn_path, commit_message) - # Log success/nothing based on return value if needed self.logger.info("Pre-tag commit attempt finished.") else: - self.logger.info("No uncommitted changes detected.") + 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) + 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}'...") try: - # git_commands.create_tag handles validation and execution + # git_commands.create_tag handles its own validation and execution + # 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 except Exception as e: - # Catch errors during tag creation (e.g., exists, invalid name) - self.logger.error(f"Failed to create tag '{tag_name}': {e}", exc_info=True) + # 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 @@ -299,47 +307,155 @@ class ActionHandler: Args: svn_path (str): Validated path to repository. - tag_name (str): The tag name to check out (already validated). + tag_name (str): The tag name to check out (already validated by caller). Returns: bool: True on successful checkout. Raises: ValueError: If uncommitted changes exist. - GitCommandError: If status check or checkout command fails. - Exception: For unexpected errors. + GitCommandError/Exception: If status check or checkout fails. """ if not tag_name: - # Should be validated by caller, but double-check - raise ValueError("Tag name required for checkout.") + raise ValueError("Tag name required for checkout.") # Should be caught earlier - self.logger.info(f"Executing checkout for tag '{tag_name}' in: {svn_path}") + self.logger.info(f"Executing checkout tag '{tag_name}' in: {svn_path}") # --- Check for Uncommitted Changes --- try: has_changes = self.git_commands.git_status_has_changes(svn_path) if has_changes: - self.logger.error("Checkout blocked: Uncommitted changes exist.") - # Raise specific error for UI to handle clearly - raise ValueError("Uncommitted changes exist. Commit or stash first.") + msg = "Uncommitted changes exist. Commit or stash first." + self.logger.error(f"Checkout blocked: {msg}") + 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"Error checking status before checkout: {e}", + 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 GitCommandError on failure checkout_success = self.git_commands.checkout_tag(svn_path, tag_name) - # git_commands.checkout_tag raises error on failure - if checkout_success: - self.logger.info(f"Tag '{tag_name}' checked out successfully.") - return True - else: - # This path should theoretically not be reached if check=True used - self.logger.error("Checkout command reported failure unexpectedly.") - # Raise generic error if this happens? - raise GitCommandError(f"Checkout failed for '{tag_name}' for unknown reasons.") + # If no exception, assume success + self.logger.info(f"Tag '{tag_name}' checked out.") + return True except Exception as e: - self.logger.error(f"Failed to checkout tag '{tag_name}': {e}", exc_info=True) - raise # Re-raise checkout errors \ No newline at end of file + # 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 + + + # --- Branch Actions --- + def execute_create_branch(self, svn_path, branch_name, start_point=None): + """ + Executes branch creation. + + Args: See git_commands.create_branch + + 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'}'.") + try: + # Delegate to git_commands, raises error on failure + self.git_commands.create_branch(svn_path, branch_name, start_point) + return True + except Exception as e: + 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. + + Args: See git_commands.checkout_branch + + Returns: True on success. + Raises: ValueError (changes exist), GitCommandError/Exception on failure. + """ + if not branch_name: + raise ValueError("Branch name required for switch.") + self.logger.info(f"Executing switch to branch '{branch_name}'.") + + # --- Check for Uncommitted Changes --- + try: + has_changes = self.git_commands.git_status_has_changes(svn_path) + if has_changes: + msg = "Uncommitted changes exist. Commit or stash first." + self.logger.error(f"Switch blocked: {msg}") + raise ValueError(msg) # Raise specific error + self.logger.debug("No uncommitted changes found.") + except Exception as e: + self.logger.error(f"Status check error before switch: {e}", + exc_info=True) + raise # Re-raise status check errors + + # --- Execute Switch --- + try: + # Delegate to git_commands, raises error on failure + success = self.git_commands.checkout_branch(svn_path, branch_name) + return success + except Exception as e: + 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. + + Args: See git_commands.delete_branch + + Returns: True on success. + Raises: GitCommandError/ValueError/Exception on failure. + """ + if not branch_name: + raise ValueError("Branch name required for delete.") + # Add checks for main/master? Done in UI layer. + self.logger.info(f"Executing delete branch '{branch_name}' (force={force}).") + try: + # Delegate to git_commands, raises error on failure + success = self.git_commands.delete_branch(svn_path, branch_name, force) + return success + except Exception as e: + # Catch errors (like not fully merged if force=False) + self.logger.error(f"Failed delete branch '{branch_name}': {e}", + exc_info=True) + raise + + + # --- ADDED: Delete Tag Action --- + def execute_delete_tag(self, svn_path, tag_name): + """ + Executes deletion for the specified tag. + + Args: + svn_path (str): Validated path to repository. + tag_name (str): The tag name to delete (validated by caller). + + Returns: + bool: True on successful deletion. + Raises: + GitCommandError/ValueError/Exception: If delete fails. + """ + if not tag_name: + # Should be validated by caller (UI layer) + raise ValueError("Tag name required for deletion.") + + self.logger.info(f"Executing delete tag '{tag_name}' in: {svn_path}") + try: + # 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 + 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 diff --git a/backup_handler.py b/backup_handler.py index d1aa4fb..cd7b623 100644 --- a/backup_handler.py +++ b/backup_handler.py @@ -4,10 +4,6 @@ import datetime import zipfile import logging -# Note: Assumes ConfigManager is available via dependency injection or other means -# if needed for settings beyond exclusions passed directly. -# For now, it only needs exclusions passed to the create method. - class BackupHandler: """Handles the creation of ZIP backups with exclusions.""" @@ -20,6 +16,9 @@ class BackupHandler: """ self.logger = logger + # Note: _parse_exclusions was moved into GitUtilityApp as it needs direct access + # 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): @@ -54,20 +53,21 @@ class BackupHandler: if not backup_base_dir: raise ValueError("Backup base directory cannot be empty.") - # Ensure backup directory exists + # Ensure backup directory exists, create if necessary if not os.path.isdir(backup_base_dir): self.logger.info(f"Creating backup base directory: {backup_base_dir}") try: + # 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) - # Re-raise as IOError for the caller + # 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 + # 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" backup_filename = f"{now_str}_backup_{safe_profile}.zip" @@ -78,20 +78,25 @@ class BackupHandler: files_added = 0 files_excluded = 0 dirs_excluded = 0 - zip_f = None # Initialize zip file object + zip_f = None # Initialize zip file object outside try block + try: - # Open ZIP file with appropriate settings + # Open ZIP file with settings for compression and large files zip_f = zipfile.ZipFile(backup_full_path, 'w', compression=zipfile.ZIP_DEFLATED, - allowZip64=True) # Support large archives + allowZip64=True) - # Walk through the source directory + # Walk through the source directory tree for root, dirs, files in os.walk(source_repo_path, topdown=True): + # --- Directory Exclusion --- - original_dirs = list(dirs) # Copy before modifying - # Exclude based on base name (case-insensitive) + # Keep a copy of original dirs list before modifying it in-place + original_dirs = list(dirs) + # Filter the dirs list: keep only those NOT in excluded_dirs_base + # Compare lowercase names for case-insensitivity dirs[:] = [d for d in dirs if d.lower() not in excluded_dirs_base] - # Log excluded directories for this level + + # Log excluded directories for this level if any were removed excluded_dirs_now = set(original_dirs) - set(dirs) if excluded_dirs_now: dirs_excluded += len(excluded_dirs_now) @@ -115,36 +120,37 @@ class BackupHandler: # If not excluded, add file to ZIP file_full_path = os.path.join(root, filename) - # Store with relative path inside ZIP archive + # Calculate relative path for storage inside ZIP archive archive_name = os.path.relpath(file_full_path, source_repo_path) try: + # Write the file to the ZIP archive zip_f.write(file_full_path, arcname=archive_name) files_added += 1 - # Log progress occasionally for large backups + # Log progress periodically for large backups if files_added % 500 == 0: self.logger.debug(f"Added {files_added} files...") except Exception as write_e: - # Log error writing specific file but allow backup to continue + # 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 ) - # Mark backup potentially incomplete? For now, just log. + # Consider marking the backup as potentially incomplete - # Log final summary after successful walk - self.logger.info(f"Backup ZIP creation process finished: {backup_full_path}") + # Log final summary after successful walk and write attempts + self.logger.info(f"Backup ZIP creation finished: {backup_full_path}") self.logger.info( f"Summary - Added: {files_added}, Excl Files: {files_excluded}, " f"Excl Dirs: {dirs_excluded}" ) - # Return the path of the created zip file on success + # Return the full path of the created ZIP file on success return backup_full_path except (OSError, zipfile.BadZipFile) as e: - # Handle OS errors and specific ZIP errors + # 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 specific types or a general IOError + # 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 @@ -152,17 +158,18 @@ class BackupHandler: # Re-raise the original exception raise finally: - # Ensure the ZIP file is always closed + # Ensure the ZIP file is always closed, even if errors occurred if zip_f: zip_f.close() self.logger.debug(f"ZIP file '{backup_full_path}' closed.") - # Clean up potentially empty/failed ZIP file + # Clean up potentially empty or failed ZIP file zip_exists = os.path.exists(backup_full_path) - # Check if zip exists but no files were added + # 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: @@ -170,5 +177,6 @@ class BackupHandler: 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? \ No newline at end of file + # Consider raising an error here if this state is critical \ No newline at end of file diff --git a/git_commands.py b/git_commands.py index 9bd23d7..46590fa 100644 --- a/git_commands.py +++ b/git_commands.py @@ -2,262 +2,602 @@ import os import subprocess import logging -import re +import re # Ensure re is imported class GitCommandError(Exception): - """ Custom exception for Git command errors. """ + """ + Custom exception for handling Git command errors. + Includes the original command and error 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. + """ super().__init__(message) self.command = command self.stderr = stderr + def __str__(self): - base = super().__str__(); details = [] - if self.command: details.append(f"Cmd: '{' '.join(map(str, self.command))}'") - if self.stderr: details.append(f"Stderr: {self.stderr.strip()}") - return f"{base} ({'; '.join(details)})" if details else base + """Return a formatted string representation of the error.""" + base_message = super().__str__() + details = [] + if self.command: + # Ensure command is list of strings for join + safe_command = [str(part) for part in self.command] + command_str = ' '.join(safe_command) + details.append(f"Command: '{command_str}'") + if self.stderr: + stderr_str = self.stderr.strip() + details.append(f"Stderr: {stderr_str}") + + if details: + details_str = '; '.join(details) + return f"{base_message} ({details_str})" + else: + return base_message + class GitCommands: - """ Manages Git commands execution. """ + """ + Manages Git commands execution, logging, and error handling. + Includes tag and branch management functionalities. + """ def __init__(self, logger): - """ Initializes with a logger. """ - if not isinstance(logger, logging.Logger): raise ValueError("Valid logger required.") + """ + Initializes the GitCommands with a logger. + + Args: + logger (logging.Logger): Logger instance for logging messages. + """ + 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.") self.logger = logger def log_and_execute(self, command, working_directory, check=True): - """ Executes a command, logs, handles errors. """ - safe_cmd = [str(p) for p in command]; cmd_str = ' '.join(safe_cmd) - self.logger.debug(f"Executing: {cmd_str}") - if not working_directory: raise ValueError("Working directory required.") - abs_path = os.path.abspath(working_directory) - if not os.path.isdir(abs_path): raise GitCommandError(f"Invalid WD: {abs_path}", safe_cmd) - cwd = abs_path; self.logger.debug(f"WD: {cwd}") - try: - startupinfo = None; creationflags = 0 - if os.name == 'nt': - startupinfo = subprocess.STARTUPINFO(); startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = subprocess.SW_HIDE - result = subprocess.run( - safe_cmd, cwd=cwd, capture_output=True, text=True, check=check, - encoding='utf-8', errors='replace', startupinfo=startupinfo, creationflags=creationflags - ) - out = result.stdout.strip() or ""; err = result.stderr.strip() or "" - self.logger.info(f"Success. Output:\n--- stdout ---\n{out}\n--- stderr ---\n{err}\n---") - return result - except subprocess.CalledProcessError as e: - err = e.stderr.strip() or ""; out = e.stdout.strip() or "" - log_msg = f"Cmd failed (code {e.returncode}) in '{cwd}'.\nCmd: {cmd_str}\nStderr: {err}\nStdout: {out}" - self.logger.error(log_msg) - raise GitCommandError(f"Git cmd failed in '{cwd}'.", safe_cmd, e.stderr) from e - except FileNotFoundError as e: self.logger.error(f"Cmd not found: '{safe_cmd[0]}'"); raise GitCommandError(f"Cmd not found: {safe_cmd[0]}", safe_cmd) from e - except PermissionError as e: self.logger.error(f"Permission denied in '{cwd}'."); raise GitCommandError(f"Permission denied", safe_cmd, str(e)) from e - except Exception as e: self.logger.exception(f"Unexpected error in '{cwd}': {e}"); raise GitCommandError(f"Unexpected error: {e}", safe_cmd) from e - - # --- Existing Methods (Prepare, Bundle, Commit, Status, Tag) --- - # (Code omitted for brevity - Ensure they are present and correct) - def prepare_svn_for_git(self, working_directory): - """ Prepares a directory for use with Git. """ - self.logger.info(f"Preparing directory for Git: '{working_directory}'") - if not working_directory: raise ValueError("WD empty.") - if not os.path.isdir(working_directory): raise GitCommandError(f"Dir not exists: {working_directory}") - gitignore = os.path.join(working_directory, ".gitignore") - git_dir = os.path.join(working_directory, ".git") - if not os.path.exists(git_dir): - self.logger.info("Initializing Git repo...") - try: self.log_and_execute(["git", "init"], working_directory, check=True); self.logger.info("Repo initialized.") - except Exception as e: self.logger.error(f"Failed init: {e}"); raise - else: self.logger.info("Repo already exists.") - self.logger.debug(f"Checking/updating gitignore: {gitignore}") - try: - entry = ".svn"; needs_write = False; content = "" - if not os.path.exists(gitignore): needs_write = True; content = f"{entry}\n"; self.logger.info("Creating .gitignore.") - else: - try: - with open(gitignore, "r", encoding='utf-8') as f: lines = f.readlines() - ignored = any(l.strip() == entry or l.strip().startswith(entry + '/') for l in lines) - if not ignored: needs_write = True; current = "".join(lines); content = f"\n{entry}\n" if not current.endswith('\n') else f"{entry}\n"; self.logger.info("Appending .svn to .gitignore.") - else: self.logger.info(".svn already ignored.") - except IOError as e: self.logger.warning(f"Cannot read gitignore: {e}") - if needs_write: - mode = 'a' if os.path.exists(gitignore) else 'w' - try: - with open(gitignore, mode, encoding='utf-8', newline='\n') as f: f.write(content) - self.logger.info("Updated .gitignore.") - except IOError as e: self.logger.error(f"Write error gitignore: {e}"); raise GitCommandError(f"Failed update gitignore: {e}") from e - except Exception as e: self.logger.exception(f"Gitignore error: {e}"); raise GitCommandError(f"Gitignore error: {e}") from e - self.logger.info("Preparation complete.") - - def create_git_bundle(self, wd, path): - """ Creates a Git bundle file. """ - norm_path = os.path.normpath(path).replace("\\", "/"); cmd = ["git", "bundle", "create", norm_path, "--all"]; self.logger.info(f"Creating bundle: {norm_path}") - try: - res = self.log_and_execute(cmd, wd, check=False) - if res.returncode != 0: - err = res.stderr.lower() if res.stderr else "" - if "refusing to create empty bundle" in err: self.logger.warning(f"Empty bundle skipped for '{wd}'.") - else: raise GitCommandError(f"Bundle cmd failed code {res.returncode}", cmd, res.stderr) - elif not os.path.exists(norm_path) or os.path.getsize(norm_path) == 0: self.logger.warning(f"Bundle file missing/empty: {norm_path}") - else: self.logger.info(f"Bundle created: '{norm_path}'.") - except (GitCommandError, ValueError) as e: self.logger.error(f"Failed create bundle for '{wd}': {e}"); raise - except Exception as e: self.logger.exception(f"Unexpected bundle error for '{wd}': {e}"); raise GitCommandError(f"Unexpected bundle error: {e}", cmd) from e - - def fetch_from_git_bundle(self, wd, path): - """ Fetches from a bundle and merges. """ - norm_path = os.path.normpath(path).replace("\\", "/"); self.logger.info(f"Fetching from '{norm_path}' into '{wd}'") - fetch_cmd = ["git", "fetch", norm_path]; merge_cmd = ["git", "merge", "FETCH_HEAD", "--no-ff"] - try: - self.logger.debug("Executing fetch..."); self.log_and_execute(fetch_cmd, wd, check=True); self.logger.info("Fetch successful.") - self.logger.debug("Executing merge..."); merge_res = self.log_and_execute(merge_cmd, wd, check=False) - out = merge_res.stdout.strip() or ""; err = merge_res.stderr.strip() or "" - if merge_res.returncode == 0: - if "already up to date" in out.lower(): self.logger.info("Already up-to-date.") - else: self.logger.info("Merge successful.") - else: - if "conflict" in (err + out).lower(): msg = f"Merge conflict fetching. Resolve in '{wd}' and commit."; self.logger.error(msg); raise GitCommandError(msg, merge_cmd, merge_res.stderr) - else: msg = f"Merge failed code {merge_res.returncode}."; self.logger.error(msg); raise GitCommandError(msg, merge_cmd, merge_res.stderr) - except (GitCommandError, ValueError) as e: self.logger.error(f"Fetch/merge error for '{wd}': {e}"); raise - except Exception as e: self.logger.exception(f"Unexpected fetch/merge error for '{wd}': {e}"); raise GitCommandError(f"Unexpected fetch/merge error: {e}") from e - - def git_commit(self, wd, msg="Autocommit"): - """ Stages all and commits. """ - self.logger.info(f"Attempting commit in '{wd}': '{msg}'") - try: - add_cmd = ["git", "add", "."]; self.logger.debug("Staging..."); self.log_and_execute(add_cmd, wd, check=True); self.logger.debug("Staged.") - commit_cmd = ["git", "commit", "-m", msg]; self.logger.debug("Committing..."); res = self.log_and_execute(commit_cmd, wd, check=False) - out_low = res.stdout.lower() if res.stdout else ""; err_low = res.stderr.lower() if res.stderr else "" - if res.returncode == 0: self.logger.info("Commit successful."); return True - elif "nothing to commit" in out_low or "no changes added" in out_low or "nothing added" in out_low or (res.returncode == 1 and not err_low and not out_low): self.logger.info("Nothing to commit."); return False - else: msg_err = f"Commit failed code {res.returncode}."; raise GitCommandError(msg_err, commit_cmd, res.stderr) - except (GitCommandError, ValueError) as e: self.logger.error(f"Commit process error: {e}"); raise - except Exception as e: self.logger.exception(f"Unexpected commit error: {e}"); raise GitCommandError(f"Unexpected commit error: {e}") from e - - def git_status_has_changes(self, wd): - """ Checks if repo has uncommitted changes. """ - self.logger.debug(f"Checking status in '{wd}'...") - try: - cmd = ["git", "status", "--porcelain"]; res = self.log_and_execute(cmd, wd, check=True) - changed = bool(res.stdout.strip()); self.logger.debug(f"Has changes: {changed}"); return changed - except (GitCommandError, ValueError) as e: self.logger.error(f"Status check error: {e}"); raise - except Exception as e: self.logger.exception(f"Unexpected status error: {e}"); raise GitCommandError(f"Unexpected status error: {e}") from e - - def list_tags(self, wd): - """ Lists tags with subjects, sorted newest first. """ - self.logger.info(f"Listing tags with subjects in '{wd}'...") - fmt = "%(refname:short)%09%(contents:subject)"; cmd = ["git", "tag", "--list", f"--format={fmt}", "--sort=-creatordate"] - tags = [] - try: - res = self.log_and_execute(cmd, wd, check=True) - for line in res.stdout.splitlines(): - if line.strip(): - parts = line.split('\t', 1); name = parts[0].strip() - subject = parts[1].strip() if len(parts) > 1 else "(No subject)" - tags.append((name, subject)) - self.logger.info(f"Found {len(tags)} tags."); self.logger.debug(f"Tags: {tags}"); return tags - except (GitCommandError, ValueError) as e: self.logger.error(f"Error listing tags: {e}"); return [] - except Exception as e: self.logger.exception(f"Unexpected error listing tags: {e}"); return [] - - def create_tag(self, wd, name, message): - """ Creates an annotated tag. """ - self.logger.info(f"Creating tag '{name}' in '{wd}'") - if not name or not message: raise ValueError("Tag name/message empty.") - pattern = r"^(?![./]|.*([./]{2,}|[.]$|\.lock$))[^ \t\n\r\f\v~^:?*[\\]+(?" + 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---" + ) + return result + + except subprocess.CalledProcessError as e: + # Log detailed error information from the failed process + 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"---" + ) + self.logger.error(error_log_msg) + # Wrap the original exception for consistent error handling + raise GitCommandError( + f"Git command failed in '{cwd}'.", + command=safe_command, + stderr=e.stderr + ) from e + + 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 + + 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 + + except Exception as e: + # Catch any other unexpected errors during execution + self.logger.exception(f"Unexpected error executing command in '{cwd}': {e}") + raise GitCommandError( + f"Unexpected error during command execution: {e}", + command=safe_command + ) from e + + + def create_git_bundle(self, working_directory, bundle_path): + """ + Creates a Git bundle file from the repository in working_directory. Args: working_directory (str): Path to the local Git repository. + bundle_path (str): Full path where the bundle file should be saved. - Returns: - str or None: Current branch name, '(DETACHED HEAD)', or None on error. + Raises: + GitCommandError: If command fails or path invalid. + ValueError: If working_directory is None or empty. """ - self.logger.debug(f"Getting current branch in '{working_directory}'...") - # `git branch --show-current` is simpler if available (Git 2.22+) - cmd_show_current = ["git", "branch", "--show-current"] + 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}") + try: - result_show = self.log_and_execute(cmd_show_current, working_directory, check=False) - # If successful and output is non-empty, that's the branch name - if result_show.returncode == 0 and result_show.stdout.strip(): - branch_name = result_show.stdout.strip() - self.logger.debug(f"Current branch (show-current): {branch_name}") - return branch_name - # If --show-current failed or gave empty output, fallback for older Git or detached HEAD - else: - self.logger.debug("--show-current failed or empty, trying symbolic-ref.") - cmd_symbolic_ref = ["git", "symbolic-ref", "--short", "HEAD"] - result_ref = self.log_and_execute(cmd_symbolic_ref, working_directory, check=False) - if result_ref.returncode == 0 and result_ref.stdout.strip(): - branch_name = result_ref.stdout.strip() - self.logger.debug(f"Current branch (symbolic-ref): {branch_name}") - return branch_name + result = self.log_and_execute( + command, + working_directory, + check=False # Check result manually + ) + + if result.returncode != 0: + 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 else: - # Not on a branch (detached HEAD) or another error occurred - self.logger.warning("Could not determine current branch name " - f"(symbolic-ref exit: {result_ref.returncode}). Assuming detached HEAD or error.") - # Check if it's detached by checking exit code 128 of symbolic-ref - if result_ref.returncode == 128: # typical exit code for detached HEAD + 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 + else: + self.logger.info(f"Git bundle created successfully: '{normalized_bundle_path}'.") + + except (GitCommandError, ValueError) as e: + 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 + + + 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. + """ + normalized_bundle_path = os.path.normpath(bundle_path).replace("\\", "/") + 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 + + 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.") + + # 2. Merge fetched changes + self.logger.debug("Executing merge command...") + merge_result = self.log_and_execute(merge_command, working_directory, check=False) # Check manually + + # 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: + 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.") + 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) + 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) + + 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}") + raise GitCommandError(f"Unexpected fetch/merge error: {e}") from e + + + 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 + add_command = ["git", "add", "."] + self.logger.debug("Staging all changes (git add .)...") + 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) + + # Analyze 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 + else: + # Unexpected error + error_msg = f"Commit command failed (code {result.returncode})." + raise GitCommandError(error_msg, commit_command, result.stderr) + + except (GitCommandError, ValueError) as e: + self.logger.error(f"Commit process error: {e}") + raise + except Exception as e: + self.logger.exception(f"Unexpected commit error: {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. """ + self.logger.debug(f"Checking Git status in '{working_directory}'...") + try: + status_command = ["git", "status", "--porcelain"] + result = self.log_and_execute(status_command, working_directory, check=True) + # Any output from porcelain means changes exist + 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 + except Exception as e: + self.logger.exception(f"Unexpected status error: {e}") + raise GitCommandError(f"Unexpected status error: {e}") from e + + + def list_tags(self, working_directory): + """ Lists tags with subjects, sorted newest first. """ + 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~^:?*[\\]+(?" # Indicate error state + return "" 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 current branch: {e}") + self.logger.exception(f"Unexpected error getting branch: {e}") return "" def list_branches(self, working_directory): - """ - Lists local Git branches. - - Args: - working_directory (str): Path to the local Git repository. - - Returns: - list: A list of local branch names (str). Empty on error. - """ + """ Lists local Git branches. """ self.logger.info(f"Listing local branches in '{working_directory}'...") cmd = ["git", "branch", "--list", "--no-color"] branches = [] try: result = self.log_and_execute(cmd, working_directory, check=True) for line in result.stdout.splitlines(): - # Remove leading '*' and whitespace from branch names - branch_name = line.lstrip('* ').strip() - if branch_name and "HEAD detached" not in branch_name: # Filter out detached HEAD message - branches.append(branch_name) - self.logger.info(f"Found {len(branches)} local branches.") + # 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 except (GitCommandError, ValueError) as e: @@ -268,22 +608,11 @@ class GitCommands: return [] def create_branch(self, working_directory, branch_name, start_point=None): - """ - Creates a new local Git branch. - - Args: - working_directory (str): Path to the local Git repository. - branch_name (str): The name for the new branch. - start_point (str, optional): Commit, tag, or branch to start from. - Defaults to current HEAD. - - Raises: - GitCommandError: If branch name invalid, exists, or command fails. - ValueError: If arguments invalid. - """ + """ 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.") - # Add validation for branch name format + if not branch_name: + raise ValueError("Branch name cannot be empty.") + # Branch name validation pattern = r"^(?![./]|.*([./]{2,}|[.]$|[/]$|@\{))[^ \t\n\r\f\v~^:?*[\\]+(?") # For branch display - # --- Create GUI Sections --- + # --- Create Main Layout Sections --- + # Profile selection is always visible at the top self._create_profile_frame() - self._create_repo_frame() # Paths and bundle names - self._create_backup_frame() # Backup settings - self._create_commit_management_frame() # Commit settings - self._create_branch_management_frame() # Branch list and actions - self._create_tag_management_frame() # Tag list and actions - self._create_function_frame() # Core Actions (Prepare, Bundle, etc.) + + # --- Create Notebook for Tabs --- + # Add padding below tabs for separation from action buttons + self.notebook = ttk.Notebook(self, padding=(0, 5, 0, 0)) + self.notebook.pack(pady=5, padx=0, fill="both", expand=True) + + # --- Create Frames for each Tab --- + # Add padding within each tab frame for content spacing + self.setup_tab_frame = ttk.Frame(self.notebook, padding=(10)) + self.commit_branch_tab_frame = ttk.Frame(self.notebook, padding=(10)) + self.tags_gitignore_tab_frame = ttk.Frame(self.notebook, padding=(10)) + + # Add frames as tabs to the notebook with descriptive text + self.notebook.add(self.setup_tab_frame, text=' Setup & Backup ') # Combined setup + self.notebook.add(self.commit_branch_tab_frame, text=' Commit & Branches ') + self.notebook.add(self.tags_gitignore_tab_frame, text=' Tags & Gitignore ') + + # --- Populate Tabs with Widgets --- + self._populate_setup_tab() + self._populate_commit_branch_tab() + self._populate_tags_gitignore_tab() + + # --- Core Actions Frame (Below Tabs) --- + self._create_function_frame() + + # --- Log Area (Bottom) --- self._create_log_area() - # --- Initial GUI State --- + # --- Initial State Configuration --- self._initialize_profile_selection() - self.toggle_backup_dir() # Set initial state of backup dir entry + # Set initial state of backup widgets based on checkbox value + self.toggle_backup_dir() def _create_profile_frame(self): @@ -437,8 +519,9 @@ class MainFrame(ttk.Frame): self.profile_frame = ttk.LabelFrame( self, text="Profile Configuration", padding=(10, 5) ) - self.profile_frame.pack(pady=5, fill="x") - # Allow dropdown column to expand horizontally + # Pack frame at the top, below potential menu bar, expand horizontally + self.profile_frame.pack(pady=(0, 5), fill="x") + # Allow dropdown column (column 1) to expand horizontally self.profile_frame.columnconfigure(1, weight=1) # Profile Label @@ -449,28 +532,33 @@ class MainFrame(ttk.Frame): self.profile_dropdown = ttk.Combobox( self.profile_frame, textvariable=self.profile_var, - state="readonly", + state="readonly", # Prevent typing custom values width=35, - values=self.initial_profile_sections + values=self.initial_profile_sections # Set initial list ) self.profile_dropdown.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=5) - # Bind selection change to load settings + # Bind selection change to load profile settings self.profile_dropdown.bind( "<>", - lambda e: self.load_profile_settings_callback(self.profile_var.get()) + lambda event: self.load_profile_settings_callback( + self.profile_var.get() + ) ) - # Trace variable for programmatic changes + # Trace variable for programmatic changes to also trigger load self.profile_var.trace_add( "write", - lambda *a: self.load_profile_settings_callback(self.profile_var.get()) + lambda *args: self.load_profile_settings_callback( + self.profile_var.get() + ) ) # Save Settings Button self.save_settings_button = ttk.Button( self.profile_frame, text="Save Settings", - command=self.save_profile_callback + command=self.save_profile_callback # Use controller's save method ) + # Place button next to the dropdown self.save_settings_button.grid(row=0, column=2, sticky=tk.W, padx=(5, 2), pady=5) self.create_tooltip(self.save_settings_button, @@ -478,222 +566,254 @@ class MainFrame(ttk.Frame): # Add Profile Button self.add_profile_button = ttk.Button( - self.profile_frame, text="Add", width=5, + self.profile_frame, + text="Add", + width=5, # Fixed small width 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) # Remove Profile Button self.remove_profile_button = ttk.Button( - self.profile_frame, text="Remove", width=8, + self.profile_frame, + text="Remove", + width=8, # Slightly wider than Add 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_frame(self): - """Creates the frame ONLY for repository paths and bundle names.""" - self.repo_frame = ttk.LabelFrame( - self, text="Repository & Bundle Paths", padding=(10, 5) - ) - self.repo_frame.pack(pady=5, fill="x") - # Define columns for easier layout management + def _populate_setup_tab(self): + """Creates and places widgets for the Setup & Backup tab.""" + parent_frame = self.setup_tab_frame + + # Create sub-frames within the tab for better organization + # Pack them vertically, expanding horizontally + repo_paths_frame = self._create_repo_paths_frame(parent_frame) + repo_paths_frame.pack(pady=(0, 5), fill="x", expand=False) + + backup_config_frame = self._create_backup_config_frame(parent_frame) + backup_config_frame.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)) + # Define columns for layout consistency col_label = 0 col_entry = 1 col_button = 2 col_indicator = 3 - # Configure entry column to expand horizontally - self.repo_frame.columnconfigure(col_entry, weight=1) + # Configure entry column (1) to expand horizontally + frame.columnconfigure(col_entry, weight=1) - # Row 0: SVN Path Entry and Browse Button + Status Indicator - svn_label = ttk.Label(self.repo_frame, text="SVN Working Copy:") + # 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(self.repo_frame, width=60) + 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) - 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())) + # Bind events to trigger status updates + self.svn_path_entry.bind( + "", + lambda e: self.update_svn_status_callback(self.svn_path_entry.get()) + ) + self.svn_path_entry.bind( + "", + lambda e: self.update_svn_status_callback(self.svn_path_entry.get()) + ) self.svn_path_browse_button = ttk.Button( - self.repo_frame, text="Browse...", width=9, + frame, text="Browse...", width=9, command=lambda: self.browse_folder_callback(self.svn_path_entry) ) - self.svn_path_browse_button.grid(row=0, column=col_button, sticky=tk.W, padx=(0, 5), pady=3) + self.svn_path_browse_button.grid(row=0, column=col_button, sticky=tk.W, + padx=(0, 5), pady=3) + # Status Indicator (Green/Red dot) self.svn_status_indicator = tk.Label( - self.repo_frame, text="", width=2, height=1, relief=tk.SUNKEN, + frame, text="", width=2, height=1, relief=tk.SUNKEN, background=self.RED, anchor=tk.CENTER ) - self.svn_status_indicator.grid(row=0, column=col_indicator, sticky=tk.E, padx=(0, 5), pady=3) - self.create_tooltip(self.svn_status_indicator, "Git repo status (Green=Ready, Red=Not Ready)") + self.svn_status_indicator.grid(row=0, column=col_indicator, sticky=tk.E, + padx=(0, 5), pady=3) + self.create_tooltip(self.svn_status_indicator, + "Git repo status (Green=Ready, Red=Not Ready)") - # Row 1: USB/Bundle Target Path Entry and Browse Button - usb_label = ttk.Label(self.repo_frame, text="Bundle Target Dir:") + # 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(self.repo_frame, width=60) + 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) self.usb_path_browse_button = ttk.Button( - self.repo_frame, text="Browse...", width=9, + 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) + self.usb_path_browse_button.grid(row=1, column=col_button, sticky=tk.W, + padx=(0, 5), pady=3) - # Row 2: Create Bundle Name Entry - create_label = ttk.Label(self.repo_frame, text="Create Bundle Name:") + # 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(self.repo_frame, width=60) + self.bundle_name_entry = ttk.Entry(frame, width=60) # Span entry across entry and button columns - self.bundle_name_entry.grid(row=2, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3) + self.bundle_name_entry.grid(row=2, column=col_entry, columnspan=2, + sticky=tk.EW, padx=5, pady=3) - # Row 3: Fetch Bundle Name Entry - fetch_label = ttk.Label(self.repo_frame, text="Fetch Bundle Name:") + # 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(self.repo_frame, width=60) - self.bundle_updated_name_entry.grid(row=3, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3) + self.bundle_updated_name_entry = ttk.Entry(frame, width=60) + # Span entry across entry and button columns + self.bundle_updated_name_entry.grid(row=3, column=col_entry, columnspan=2, + sticky=tk.EW, padx=5, pady=3) + + return frame # Return the created frame - def _create_backup_frame(self): - """Creates the frame for backup configuration.""" - self.backup_frame = ttk.LabelFrame( - self, text="Backup Configuration (ZIP)", padding=(10, 5) - ) - self.backup_frame.pack(pady=5, fill="x") - # Define column indices + def _create_backup_config_frame(self, parent): + """Creates the sub-frame for backup configuration.""" + frame = ttk.LabelFrame(parent, text="Backup Configuration (ZIP)", + padding=(10, 5)) + # Define columns col_label = 0 col_entry = 1 col_button = 2 # Configure entry column to expand - self.backup_frame.columnconfigure(col_entry, weight=1) + frame.columnconfigure(col_entry, weight=1) # Row 0: Autobackup Checkbox self.autobackup_checkbox = ttk.Checkbutton( - self.backup_frame, text="Automatic Backup before Create/Fetch", + frame, text="Automatic Backup before Create/Fetch", variable=self.autobackup_var, command=self.toggle_backup_dir ) - self.autobackup_checkbox.grid(row=0, column=col_label, columnspan=3, sticky=tk.W, padx=5, pady=(5, 0)) + # Span checkbox across all columns + self.autobackup_checkbox.grid(row=0, column=col_label, columnspan=3, + sticky=tk.W, padx=5, pady=(5, 0)) # Row 1: Backup Directory Entry and Browse Button - backup_dir_label = ttk.Label(self.backup_frame, text="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( - self.backup_frame, textvariable=self.backup_dir_var, - width=60, state=tk.DISABLED + 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( - self.backup_frame, text="Browse...", width=9, - command=self.browse_backup_dir, state=tk.DISABLED + 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) + self.backup_dir_button.grid(row=1, column=col_button, sticky=tk.W, + padx=(0, 5), pady=5) # Row 2: Exclude Extensions Entry - exclude_label = ttk.Label(self.backup_frame, text="Exclude Extensions:") + exclude_label = ttk.Label(frame, text="Exclude Extensions:") exclude_label.grid(row=2, column=col_label, sticky=tk.W, padx=5, pady=5) self.backup_exclude_entry = ttk.Entry( - self.backup_frame, textvariable=self.backup_exclude_extensions_var, width=60 + frame, textvariable=self.backup_exclude_extensions_var, width=60 ) # Span entry across entry and button columns - self.backup_exclude_entry.grid(row=2, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=5) - self.create_tooltip(self.backup_exclude_entry, "Comma-separated extensions (e.g., .log,.tmp,.bak)") + self.backup_exclude_entry.grid(row=2, column=col_entry, columnspan=2, + sticky=tk.EW, padx=5, pady=5) + self.create_tooltip(self.backup_exclude_entry, + "Comma-separated extensions (e.g., .log,.tmp,.bak)") + + return frame # Return the created frame - # --- NEW: Commit Management Frame --- - def _create_commit_management_frame(self): - """Creates the frame for commit message and related actions.""" - self.commit_frame = ttk.LabelFrame( - self, text="Commit Management", padding=(10, 5) - ) - self.commit_frame.pack(pady=5, fill="x") - # Configure columns - self.commit_frame.columnconfigure(1, weight=1) # Entry expands + def _populate_commit_branch_tab(self): + """Creates and places widgets for the Commit & Branches tab.""" + parent_frame = self.commit_branch_tab_frame + # Configure grid columns for overall tab layout + parent_frame.columnconfigure(0, weight=1) # Column with listbox expands + parent_frame.rowconfigure(1, weight=1) # Row with listbox expands vertically - # Row 0: Autocommit Checkbox (relevant for Create Bundle) + # --- Commit Section (Top) --- + commit_subframe = self._create_commit_management_frame(parent_frame) + commit_subframe.grid(row=0, column=0, columnspan=2, # Span both columns + sticky="ew", padx=0, pady=(0, 10)) + + # --- Branch Section (Bottom) --- + branch_subframe = self._create_branch_management_frame(parent_frame) + branch_subframe.grid(row=1, column=0, columnspan=2, # Span both columns + sticky="nsew", padx=0, pady=0) + + + 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 (for Create Bundle action) self.autocommit_checkbox = ttk.Checkbutton( - self.commit_frame, - text="Autocommit before 'Create Bundle' (uses message below)", - variable=self.autocommit_var, - state=tk.DISABLED # State depends on repo readiness + frame, text="Autocommit before 'Create Bundle' (uses message below)", + variable=self.autocommit_var, state=tk.DISABLED ) - self.autocommit_checkbox.grid(row=0, column=0, columnspan=3, # Span all columns + self.autocommit_checkbox.grid(row=0, column=0, columnspan=3, sticky="w", padx=5, pady=(5, 3)) self.create_tooltip(self.autocommit_checkbox, "If checked, commit changes using the message before Create Bundle.") # Row 1: Commit Message Entry + Manual Commit Button - commit_msg_label = ttk.Label(self.commit_frame, text="Commit Message:") + commit_msg_label = ttk.Label(frame, text="Commit Message:") commit_msg_label.grid(row=1, column=0, sticky="w", padx=5, pady=3) self.commit_message_entry = ttk.Entry( - self.commit_frame, - textvariable=self.commit_message_var, - width=50, # Adjust width as needed - state=tk.DISABLED # State depends on repo readiness + frame, textvariable=self.commit_message_var, width=50, state=tk.DISABLED ) 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 commit before tagging/bundling.") + "Message for manual commit or autocommit.") - # Manual Commit Button self.commit_button = ttk.Button( - self.commit_frame, - text="Commit Changes", - width=15, # Adjusted width - command=self.manual_commit_callback, # Connect to controller - state=tk.DISABLED # State depends on repo readiness + frame, text="Commit Changes", width=15, + command=self.manual_commit_callback, state=tk.DISABLED ) 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 the provided message.") + "Manually commit staged changes with this message.") + + return frame # Return the created frame - # --- NEW: Branch Management Frame --- - def _create_branch_management_frame(self): - """Creates the frame for branch operations.""" - self.branch_frame = ttk.LabelFrame( - self, text="Branch Management", padding=(10, 5) - ) - self.branch_frame.pack(pady=5, fill="x") - # Configure grid - self.branch_frame.columnconfigure(1, weight=1) # Listbox expands - self.branch_frame.rowconfigure(2, weight=1) # Listbox expands vertically + def _create_branch_management_frame(self, parent): + """Creates the sub-frame for branch operations.""" + frame = ttk.LabelFrame(parent, text="Branches", padding=5) + # Configure grid columns within this frame + frame.columnconfigure(0, weight=1) # Listbox column expands + frame.rowconfigure(2, weight=1) # Listbox row expands # Row 0: Current Branch Display - current_branch_label = ttk.Label(self.branch_frame, text="Current Branch:") + current_branch_label = ttk.Label(frame, text="Current Branch:") current_branch_label.grid(row=0, column=0, sticky="w", padx=5, pady=3) self.current_branch_display = ttk.Label( - self.branch_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 + frame, textvariable=self.current_branch_var, + font=("Segoe UI", 9, "bold"), relief=tk.SUNKEN, padding=(3, 1) ) - # Span across listbox and button column? Or just listbox column? Let's span 2 - self.current_branch_display.grid(row=0, column=1, columnspan=2, + # Span display across listbox and button columns? Or just listbox? + self.current_branch_display.grid(row=0, column=1, columnspan=2, # Span 2 sticky="ew", padx=5, pady=3) self.create_tooltip(self.current_branch_display, - "The currently active branch or detached HEAD state.") + "The currently active branch or state.") # Row 1: Listbox Label - branch_list_label = ttk.Label(self.branch_frame, text="Local Branches:") - branch_list_label.grid(row=1, column=0, columnspan=3, # Span columns below - sticky="w", padx=5, pady=(10, 0)) # Add top padding + branch_list_label = ttk.Label(frame, text="Local Branches:") + branch_list_label.grid(row=1, column=0, columnspan=3, # Span all columns + sticky="w", padx=5, pady=(10, 0)) - # Row 2: Listbox + Scrollbar (in a subframe) - branch_list_frame = ttk.Frame(self.branch_frame) - # Span across first 3 columns (label, listbox, spacer?) + # 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 - exportselection=False, - selectmode=tk.SINGLE, - font=("Consolas", 9) # Monospaced font for potential alignment + branch_list_frame, height=5, exportselection=False, + selectmode=tk.SINGLE, font=("Consolas", 9) ) self.branch_listbox.grid(row=0, column=0, sticky="nsew") - # Vertical Scrollbar branch_scrollbar = ttk.Scrollbar( - branch_list_frame, - orient=tk.VERTICAL, + branch_list_frame, orient=tk.VERTICAL, command=self.branch_listbox.yview ) branch_scrollbar.grid(row=0, column=1, sticky="ns") @@ -702,11 +822,11 @@ class MainFrame(ttk.Frame): "Select a branch for actions (Switch, Delete).") # Row 2, Column 3: Vertical Button Frame for Branch Actions - branch_button_frame = ttk.Frame(self.branch_frame) - branch_button_frame.grid(row=2, column=3, sticky="ns", # Align North-South - padx=(5, 0), pady=(0, 5)) + branch_button_frame = ttk.Frame(frame) + branch_button_frame.grid(row=2, column=3, sticky="ns", # North-South align + padx=(10, 5), pady=(0, 5)) # Add left padding - button_width_branch = 18 # Consistent width for vertical buttons + button_width_branch = 18 # Consistent width self.refresh_branches_button = ttk.Button( branch_button_frame, text="Refresh List", width=button_width_branch, @@ -727,60 +847,50 @@ class MainFrame(ttk.Frame): command=self.switch_branch_callback, state=tk.DISABLED ) self.switch_branch_button.pack(side=tk.TOP, fill=tk.X, pady=3) - self.create_tooltip(self.switch_branch_button, "Checkout the selected branch.") + self.create_tooltip(self.switch_branch_button, "Checkout 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 the selected local branch (requires confirmation).") + self.create_tooltip(self.delete_branch_button, "Delete selected local branch.") + + return frame # Return the created frame - # --- NEW: Tag Management Frame --- - def _create_tag_management_frame(self): - """Creates the frame for tag operations.""" - self.tag_frame = ttk.LabelFrame( - self, text="Tag Management", padding=(10, 5) - ) - self.tag_frame.pack(pady=5, fill="x") - # Configure grid columns - # Col 0: Listbox (Expands) - # Col 1: Vertical Button Column (Fixed Width) - self.tag_frame.columnconfigure(0, weight=1) - self.tag_frame.rowconfigure(0, weight=1) # Listbox expands vertically + def _populate_tags_gitignore_tab(self): + """Creates and places widgets for the Tags & Gitignore tab.""" + parent_frame = self.tags_gitignore_tab_frame + # Configure grid + parent_frame.columnconfigure(0, weight=1) # Listbox expands + parent_frame.rowconfigure(0, weight=1) # Listbox expands vertically - # Row 0, Column 0: Listbox + Scrollbar Frame - tag_list_frame = ttk.Frame(self.tag_frame) + # --- Tag Listing Area --- + tag_list_frame = ttk.LabelFrame(parent_frame, text="Tags", padding=5) tag_list_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) tag_list_frame.rowconfigure(0, weight=1) tag_list_frame.columnconfigure(0, weight=1) self.tag_listbox = tk.Listbox( - tag_list_frame, - height=6, # Initial rows visible - exportselection=False, - selectmode=tk.SINGLE, - font=("Consolas", 9) # Monospaced font for alignment + tag_list_frame, height=8, exportselection=False, + selectmode=tk.SINGLE, font=("Consolas", 9) ) self.tag_listbox.grid(row=0, column=0, sticky="nsew") tag_scrollbar = ttk.Scrollbar( - tag_list_frame, - orient=tk.VERTICAL, - command=self.tag_listbox.yview + tag_list_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 to checkout.") + "Tags (newest first) with messages. Select for actions.") - # Row 0, Column 1: Vertical Button Frame for Tags/Gitignore - tag_button_frame = ttk.Frame(self.tag_frame) - tag_button_frame.grid(row=0, column=1, sticky="ns", # North-South alignment - padx=(5, 0), pady=5) + # --- Tag/Gitignore Action Buttons Area --- (Vertical column) + tag_button_frame = ttk.Frame(parent_frame) + tag_button_frame.grid(row=0, column=1, rowspan=2, # Span rows potentially + sticky="ns", padx=(0, 5), pady=5) - button_width_tag = 18 # Consistent width for this column + button_width_tag = 18 # Consistent width self.refresh_tags_button = ttk.Button( tag_button_frame, text="Refresh Tags", width=button_width_tag, @@ -795,7 +905,7 @@ class MainFrame(ttk.Frame): ) self.create_tag_button.pack(side=tk.TOP, fill=tk.X, pady=3) self.create_tooltip(self.create_tag_button, - "Commit changes (requires message) and create new tag.") + "Commit changes (if message provided) & create tag.") self.checkout_tag_button = ttk.Button( tag_button_frame, text="Checkout Selected Tag", width=button_width_tag, @@ -805,7 +915,16 @@ class MainFrame(ttk.Frame): self.create_tooltip(self.checkout_tag_button, "Switch to selected tag (Detached HEAD).") - # Edit .gitignore button moved here + # --- ADDED: Delete Tag Button --- + self.delete_tag_button = ttk.Button( + tag_button_frame, text="Delete Selected Tag", width=button_width_tag, + command=self.delete_tag_callback, state=tk.DISABLED + ) + self.delete_tag_button.pack(side=tk.TOP, fill=tk.X, pady=3) + self.create_tooltip(self.delete_tag_button, + "Delete the selected tag locally.") + + # Edit .gitignore button (also in this column) self.edit_gitignore_button = ttk.Button( tag_button_frame, text="Edit .gitignore", width=button_width_tag, command=self.open_gitignore_editor_callback, state=tk.DISABLED @@ -816,15 +935,15 @@ class MainFrame(ttk.Frame): def _create_function_frame(self): - """Creates the frame holding the main Core Action buttons.""" + """Creates the frame holding the Core Action buttons (below tabs).""" self.function_frame = ttk.LabelFrame( self, text="Core Actions", padding=(10, 10) ) - self.function_frame.pack(pady=(10, 5), fill="x", anchor=tk.N) + 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) - # Center the buttons within the frame - button_subframe.pack() + button_subframe.pack() # Default pack behavior centers horizontally self.prepare_svn_button = ttk.Button( button_subframe, text="Prepare SVN Repo", @@ -853,9 +972,10 @@ class MainFrame(ttk.Frame): def _create_log_area(self): """Creates the scrolled text area for logging output.""" - log_frame = ttk.Frame(self.master) + 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=(5, 10)) + padx=10, pady=(0, 10)) # Padding only below self.log_text = scrolledtext.ScrolledText( log_frame, height=8, width=100, # Adjusted height @@ -869,7 +989,7 @@ class MainFrame(ttk.Frame): try: from config_manager import DEFAULT_PROFILE except ImportError: - DEFAULT_PROFILE = "default" + DEFAULT_PROFILE = "default" # Fallback if DEFAULT_PROFILE in self.initial_profile_sections: self.profile_var.set(DEFAULT_PROFILE) @@ -891,9 +1011,9 @@ class MainFrame(ttk.Frame): def browse_backup_dir(self): """Opens a directory selection dialog for backup directory.""" initial = self.backup_dir_var.get() or DEFAULT_BACKUP_DIR - dirname = filedialog.askdirectory( - initialdir=initial, title="Select Backup Dir", parent=self.master - ) + dirname = filedialog.askdirectory(initialdir=initial, + title="Select Backup Dir", + parent=self.master) if dirname: self.backup_dir_var.set(dirname) @@ -903,6 +1023,7 @@ class MainFrame(ttk.Frame): 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" + if hasattr(self, 'svn_status_indicator'): self.svn_status_indicator.config(background=color) self.update_tooltip(self.svn_status_indicator, tip) @@ -917,10 +1038,14 @@ class MainFrame(ttk.Frame): self.profile_dropdown['values'] = sections # Maintain selection logic 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("") + 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): @@ -935,18 +1060,23 @@ class MainFrame(ttk.Frame): try: if self.tag_listbox.cget("fg") == "grey": self.tag_listbox.config(fg='SystemWindowText') - except tk.TclError: pass + except tk.TclError: + pass # Ignore color errors # Insert items for name, subject in tags_with_subjects: - display = f"{name}\t({subject})" + display = f"{name}\t({subject})" # Tab separation self.tag_listbox.insert(tk.END, display) else: # Show placeholder self.tag_listbox.insert(tk.END, "(No tags found)") - try: self.tag_listbox.config(fg="grey") - except tk.TclError: pass - except tk.TclError as e: logging.error(f"TclError updating tags: {e}") - except Exception as e: logging.error(f"Error updating tags: {e}", exc_info=True) + try: + self.tag_listbox.config(fg="grey") + except tk.TclError: + pass # Ignore color errors + except tk.TclError as e: + logging.error(f"TclError updating tags: {e}") + except Exception as e: + logging.error(f"Error updating tags: {e}", exc_info=True) def get_selected_tag(self): @@ -956,9 +1086,10 @@ class MainFrame(ttk.Frame): if indices: item = self.tag_listbox.get(indices[0]) if item != "(No tags found)": - name = item.split('\t', 1)[0] # Text before first tab - return name.strip() - return None + # Get text before the first tab + tag_name = item.split('\t', 1)[0] + return tag_name.strip() + return None # No selection or invalid item def update_branch_list(self, branches): @@ -967,7 +1098,7 @@ class MainFrame(ttk.Frame): logging.error("Branch listbox missing for update.") return try: - current_branch = self.current_branch_var.get() # Get displayed name + current = self.current_branch_var.get() # Get displayed current branch self.branch_listbox.delete(0, tk.END) if branches: # Reset color if needed @@ -977,21 +1108,24 @@ class MainFrame(ttk.Frame): except tk.TclError: pass # Insert branches, highlight current for branch in branches: - is_current = (branch == current_branch) - display_name = f"* {branch}" if is_current else f" {branch}" - self.branch_listbox.insert(tk.END, display_name) - # Apply styling for current branch + is_current = (branch == current) + # Add '*' prefix for current branch display + display = f"* {branch}" if is_current else f" {branch}" + self.branch_listbox.insert(tk.END, display) + # Highlight current branch in the list if is_current: self.branch_listbox.itemconfig( tk.END, {'fg': 'blue', 'selectbackground': 'lightblue'} ) else: - # Show placeholder + # Show placeholder if no branches self.branch_listbox.insert(tk.END, "(No local branches?)") try: self.branch_listbox.config(fg="grey") except tk.TclError: pass - except tk.TclError as e: logging.error(f"TclError updating branches: {e}") - except Exception as e: logging.error(f"Error updating branches: {e}", exc_info=True) + except tk.TclError as e: + logging.error(f"TclError updating branches: {e}") + except Exception as e: + logging.error(f"Error updating branches: {e}", exc_info=True) def get_selected_branch(self): @@ -1000,43 +1134,58 @@ class MainFrame(ttk.Frame): indices = self.branch_listbox.curselection() if indices: item = self.branch_listbox.get(indices[0]) - # Remove potential '*' prefix and strip whitespace + # Remove potential '*' prefix and leading/trailing whitespace return item.lstrip("* ").strip() - return None + return None # No selection def set_current_branch_display(self, branch_name): """Updates the label showing the current branch.""" if hasattr(self, 'current_branch_var'): - if branch_name: - self.current_branch_var.set(branch_name) - else: - # Handle detached HEAD or error state more clearly - self.current_branch_var.set("(DETACHED or N/A)") + # 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) # --- Dialog Wrappers --- def ask_new_profile_name(self): - return simpledialog.askstring("Add Profile", "Enter new profile name:", parent=self.master) + """Asks the user for a new profile name.""" + return simpledialog.askstring("Add Profile", "Enter new profile name:", + parent=self.master) + def show_error(self, title, message): + """Displays an error message box.""" 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) + 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) + # --- Tooltip Helpers --- def create_tooltip(self, widget, text): + """Creates a tooltip for a given widget.""" tooltip = Tooltip(widget, text) + # Use add='+' to avoid overwriting other bindings widget.bind("", lambda e, tt=tooltip: tt.showtip(), add='+') widget.bind("", lambda e, tt=tooltip: tt.hidetip(), add='+') + # Hide tooltip also when clicking the widget widget.bind("", lambda e, tt=tooltip: tt.hidetip(), add='+') + def update_tooltip(self, widget, text): - # Simple recreation is often easiest + """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