From 6081a219090b3bc8174a5430ddf4361c2e57741b Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Mon, 7 Apr 2025 15:01:25 +0200 Subject: [PATCH] modificato .gitignore --- GitUtility.py | 750 +++++++++++++++++++++------------- git_commands.py | 1030 ++++++++++++++++------------------------------- gui.py | 652 +++++++++++++++++++++--------- 3 files changed, 1283 insertions(+), 1149 deletions(-) diff --git a/GitUtility.py b/GitUtility.py index 5523a90..9e886e8 100644 --- a/GitUtility.py +++ b/GitUtility.py @@ -12,7 +12,7 @@ from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR from git_commands import GitCommands, GitCommandError from logger_config import setup_logger # Import GUI classes -from gui import MainFrame, GitignoreEditorWindow, CreateTagDialog +from gui import MainFrame, GitignoreEditorWindow, CreateTagDialog, CreateBranchDialog # Import Handler classes from backup_handler import BackupHandler from profile_handler import ProfileHandler @@ -34,7 +34,6 @@ class GitSvnSyncApp: """ self.master = master master.title("Git SVN Sync Tool") - # Handle window close event gracefully master.protocol("WM_DELETE_WINDOW", self.on_closing) # --- Early Logger Setup --- @@ -44,7 +43,7 @@ class GitSvnSyncApp: ) self.logger = logging.getLogger("GitSvnSyncApp") - # Initialize Core Components + # --- Initialize Core Components --- try: self.config_manager = ConfigManager(self.logger) self.git_commands = GitCommands(self.logger) @@ -55,34 +54,40 @@ class GitSvnSyncApp: self.logger, self.git_commands, self.backup_handler ) except Exception as e: - self.logger.critical(f"Failed component initialization: {e}", - exc_info=True) + 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 + return # Stop initialization - # Create GUI Main Frame, passing necessary callbacks + # --- Create GUI Main Frame --- try: self.main_frame = MainFrame( master, load_profile_settings_cb=self.load_profile_settings, browse_folder_cb=self.browse_folder, update_svn_status_cb=self.update_svn_status_indicator, - # Action Callbacks + # Core Action Callbacks prepare_svn_for_git_cb=self.ui_prepare_svn, create_git_bundle_cb=self.ui_create_bundle, fetch_from_git_bundle_cb=self.ui_fetch_bundle, manual_backup_cb=self.ui_manual_backup, - manual_commit_cb=self.ui_manual_commit, - create_tag_cb=self.ui_create_tag, - checkout_tag_cb=self.ui_checkout_tag, - refresh_tags_cb=self.refresh_tag_list, - open_gitignore_editor_cb=self.open_gitignore_editor, - save_profile_cb=self.ui_save_settings, - # Profile Management Callbacks + # Profile/Settings Callbacks add_profile_cb=self.ui_add_profile, remove_profile_cb=self.ui_remove_profile, - # Pass instances/data needed by GUI + save_profile_cb=self.ui_save_settings, + # Commit/Gitignore Callbacks + manual_commit_cb=self.ui_manual_commit, + open_gitignore_editor_cb=self.open_gitignore_editor, + # Tag Callbacks + refresh_tags_cb=self.refresh_tag_list, + create_tag_cb=self.ui_create_tag, + checkout_tag_cb=self.ui_checkout_tag, + # Branch Callbacks + refresh_branches_cb=self.refresh_branch_list, + create_branch_cb=self.ui_create_branch, + switch_branch_cb=self.ui_switch_branch, + delete_branch_cb=self.ui_delete_branch, + # Pass instances/data if needed by GUI config_manager_instance=self.config_manager, profile_sections_list=self.profile_handler.get_profile_list() ) @@ -92,7 +97,7 @@ class GitSvnSyncApp: master.destroy() return - # Configure full logger now that GUI exists + # --- Enhanced Logger Setup --- self.logger = setup_logger(self.main_frame.log_text) # Ensure all components use the final logger self.config_manager.logger = self.logger @@ -101,52 +106,41 @@ class GitSvnSyncApp: self.backup_handler.logger = self.logger self.action_handler.logger = self.logger - # Initial Application State Setup + # --- Initial Application State --- self.logger.info("Application initializing...") initial_profile = self.main_frame.profile_var.get() if initial_profile: self.logger.debug(f"Initial profile: '{initial_profile}'. Loading...") - # Load settings (called automatically by trace on profile_var) + # Load settings (called automatically by trace) else: self.logger.warning("No profile selected on startup.") - self._clear_and_disable_fields() # Initial state if no profile + self._clear_and_disable_fields() self.logger.info("Application started successfully.") def on_closing(self): - """Handles the event when the user tries to close the window.""" + """Handles window close event.""" self.logger.info("Application closing.") - # Add cleanup logic if needed self.master.destroy() - # --- ADDED BACK: Helper methods to get/validate paths from GUI --- + # --- Helper methods to get/validate paths from GUI --- def _get_and_validate_svn_path(self, operation_name="Operation"): """Retrieves and validates the SVN path from the GUI.""" - # Check if main_frame and widget exist - if not hasattr(self, 'main_frame') or \ - not self.main_frame.winfo_exists() or \ - not hasattr(self.main_frame, 'svn_path_entry'): - self.logger.error(f"{operation_name}: GUI component unavailable.") - # Raise error or return None? Return None to allow caller to handle. - self.main_frame.show_error("Internal Error", "SVN Path widget not found.") - return None + if not hasattr(self, 'main_frame'): + self.logger.error(f"{operation_name}: GUI missing.") + return None svn_path_str = self.main_frame.svn_path_entry.get().strip() if not svn_path_str: - self.logger.error(f"{operation_name}: SVN Path is empty.") - self.main_frame.show_error("Input Error", "SVN Path cannot be empty.") + self.logger.error(f"{operation_name}: SVN Path empty.") + self.main_frame.show_error("Input Error", "SVN Path empty.") return None abs_path = os.path.abspath(svn_path_str) if not os.path.isdir(abs_path): - self.logger.error( - f"{operation_name}: Invalid directory path: {abs_path}" - ) - self.main_frame.show_error( - "Input Error", - f"Invalid SVN path (not a directory):\n{abs_path}" - ) + 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}") return None self.logger.debug(f"{operation_name}: Validated SVN path: {abs_path}") @@ -155,35 +149,24 @@ class GitSvnSyncApp: def _get_and_validate_usb_path(self, operation_name="Operation"): """Retrieves and validates the USB/Bundle Target path from the GUI.""" - # Check if main_frame and widget exist - if not hasattr(self, 'main_frame') or \ - not self.main_frame.winfo_exists() or \ - not hasattr(self.main_frame, 'usb_path_entry'): - self.logger.error(f"{operation_name}: GUI component unavailable.") - self.main_frame.show_error("Internal Error", "USB Path widget not found.") - return None + if not hasattr(self, 'main_frame'): + self.logger.error(f"{operation_name}: GUI missing.") + return None usb_path_str = self.main_frame.usb_path_entry.get().strip() if not usb_path_str: - self.logger.error(f"{operation_name}: Bundle Target Dir path empty.") - self.main_frame.show_error("Input Error", - "Bundle Target Directory cannot be empty.") + self.logger.error(f"{operation_name}: Bundle Target empty.") + self.main_frame.show_error("Input Error", "Bundle Target empty.") return None abs_path = os.path.abspath(usb_path_str) if not os.path.isdir(abs_path): - self.logger.error( - f"{operation_name}: Invalid Bundle Target directory: {abs_path}" - ) - self.main_frame.show_error( - "Input Error", - f"Invalid Bundle Target path (not a directory):\n{abs_path}" - ) + self.logger.error(f"{operation_name}: Invalid Bundle Target: {abs_path}") + self.main_frame.show_error("Input Error", f"Invalid Bundle Target:\n{abs_path}") return None self.logger.debug(f"{operation_name}: Validated Bundle Target path: {abs_path}") return abs_path - # --- END: Added back helper methods --- # --- Profile Handling Wrappers --- @@ -198,8 +181,8 @@ class GitSvnSyncApp: profile_data = self.profile_handler.load_profile_data(profile_name) if profile_data and hasattr(self, 'main_frame'): - # Update GUI fields with loaded data - mf = self.main_frame + mf = self.main_frame # Alias + # Update GUI fields 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) @@ -215,27 +198,30 @@ class GitSvnSyncApp: mf.backup_exclude_extensions_var.set( profile_data.get("backup_exclude_extensions", ".log,.tmp") ) - mf.toggle_backup_dir() + mf.toggle_backup_dir() # Update backup dir entry state - # Update status indicators and dependent buttons + # Update status indicators and enable general buttons svn_path = profile_data.get("svn_working_copy_path", "") - self.update_svn_status_indicator(svn_path) - # Enable general buttons + self.update_svn_status_indicator(svn_path) # Updates state widgets self._enable_function_buttons() - # Refresh tag list if repo ready + # 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() # Call refresh method directly + self.refresh_tag_list() + self.refresh_branch_list() # Refresh branches + self.update_current_branch_display() # Update current branch label else: - mf.update_tag_list([]) # Clear tags if not ready + # Clear lists if not ready + mf.update_tag_list([]) + mf.update_branch_list([]) + mf.set_current_branch_display("") self.logger.info(f"Settings loaded successfully for '{profile_name}'.") elif not profile_data: - # Profile loading failed (logged by handler) - self.main_frame.show_error("Load Error", - f"Could not load profile '{profile_name}'.") + # 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.") @@ -246,40 +232,42 @@ class GitSvnSyncApp: self.logger.debug("UI Request: Save Settings button clicked.") profile = self.main_frame.profile_var.get() if not profile: - self.logger.warning("Save requested but no profile selected.") self.main_frame.show_error("Save Error", "No profile selected.") - return + return # Return False? No, just 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 # 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 for profile '{profile}' saved.") + self.main_frame.show_info("Settings Saved", f"Settings saved for '{profile}'.") else: - # Error message likely shown by handler or save method - self.main_frame.show_error("Save Error", - f"Failed to save settings for '{profile}'.") + # 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 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 {} # Return empty dict if GUI not ready + return None # Return None or empty dict? None indicates failure better. mf = self.main_frame + # Read values from widgets/variables data = { "svn_working_copy_path": mf.svn_path_entry.get(), "usb_drive_path": mf.usb_path_entry.get(), "bundle_name": mf.bundle_name_entry.get(), "bundle_name_updated": mf.bundle_updated_name_entry.get(), - "autocommit": mf.autocommit_var.get(), # Gets boolean value + "autocommit": mf.autocommit_var.get(), # Boolean "commit_message": mf.commit_message_var.get(), - "autobackup": mf.autobackup_var.get(), # Gets boolean value + "autobackup": mf.autobackup_var.get(), # Boolean "backup_dir": mf.backup_dir_var.get(), "backup_exclude_extensions": mf.backup_exclude_extensions_var.get() } @@ -288,59 +276,50 @@ class GitSvnSyncApp: def ui_add_profile(self): """Callback for the 'Add Profile' button.""" - self.logger.debug("UI Request: Add Profile button clicked.") + self.logger.debug("UI Request: Add Profile.") new_name = self.main_frame.ask_new_profile_name() if not new_name: - self.logger.info("Add profile cancelled.") - return + self.logger.info("Add profile cancelled."); return new_name = new_name.strip() if not new_name: - self.main_frame.show_error("Error", "Profile name cannot be empty.") - return + self.main_frame.show_error("Error", "Profile name cannot be empty."); return - # Delegate adding logic to ProfileHandler + # Delegate adding logic success = self.profile_handler.add_new_profile(new_name) if success: # Update GUI dropdown and select the new profile sections = self.profile_handler.get_profile_list() self.main_frame.update_profile_dropdown(sections) - self.main_frame.profile_var.set(new_name) # Triggers load via trace + self.main_frame.profile_var.set(new_name) # Triggers load self.main_frame.show_info("Profile Added", f"Profile '{new_name}' created.") else: - # Handler logged the reason (exists or error) - # Show specific error? Handler currently doesn't return reason. - self.main_frame.show_error("Error", f"Could not add profile '{new_name}'. " - f"It might already exist.") + # Handler logged reason (exists or error) + self.main_frame.show_error("Error", f"Could not add profile '{new_name}'.") def ui_remove_profile(self): """Callback for the 'Remove Profile' button.""" - self.logger.debug("UI Request: Remove Profile button clicked.") - profile_to_remove = self.main_frame.profile_var.get() - if not profile_to_remove: - self.main_frame.show_error("Error", "No profile selected.") - return - if profile_to_remove == DEFAULT_PROFILE: - self.main_frame.show_error("Error", f"Cannot remove '{DEFAULT_PROFILE}'.") - return + self.logger.debug("UI Request: Remove Profile.") + profile = self.main_frame.profile_var.get() + if not profile: + self.main_frame.show_error("Error", "No profile selected."); return + if profile == DEFAULT_PROFILE: + self.main_frame.show_error("Error", f"Cannot remove '{DEFAULT_PROFILE}'."); return # Confirm with user - confirm_msg = f"Remove profile '{profile_to_remove}'?" - if self.main_frame.ask_yes_no("Remove Profile", confirm_msg): - # Delegate removal to ProfileHandler - success = self.profile_handler.remove_existing_profile(profile_to_remove) + msg = f"Remove profile '{profile}'?" + if self.main_frame.ask_yes_no("Remove Profile", msg): + # Delegate removal + success = self.profile_handler.remove_existing_profile(profile) if success: sections = self.profile_handler.get_profile_list() - # Update dropdown, selection changes trigger load - self.main_frame.update_profile_dropdown(sections) - self.main_frame.show_info("Profile Removed", - f"Profile '{profile_to_remove}' removed.") + self.main_frame.update_profile_dropdown(sections) # Triggers load + self.main_frame.show_info("Removed", f"Profile '{profile}' removed.") else: - # Handler logged the reason - self.main_frame.show_error("Error", - f"Failed to remove '{profile_to_remove}'.") + # Handler logged reason + self.main_frame.show_error("Error", f"Failed to remove '{profile}'.") else: self.logger.info("Profile removal cancelled.") @@ -358,7 +337,7 @@ class GitSvnSyncApp: self.logger.debug(f"Selected: {directory}") entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) - # Trigger status update if SVN path changed + # Update status if SVN path changed if entry_widget == self.main_frame.svn_path_entry: self.update_svn_status_indicator(directory) else: @@ -377,37 +356,51 @@ class GitSvnSyncApp: # Update indicator & Prepare button via GUI method mf.update_svn_indicator(is_ready) - # Determine states for other widgets + # Determine states for other widgets based on validity/readiness gitignore_state = tk.NORMAL if is_valid else tk.DISABLED - commit_tag_state = tk.NORMAL if is_ready else tk.DISABLED + commit_tag_branch_state = tk.NORMAL if is_ready else tk.DISABLED - # Apply states + # 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_state) + mf.commit_message_entry.config(state=commit_tag_branch_state) if hasattr(mf, 'autocommit_checkbox'): - mf.autocommit_checkbox.config(state=commit_tag_state) + mf.autocommit_checkbox.config(state=commit_tag_branch_state) if hasattr(mf, 'commit_button'): # Manual commit button - mf.commit_button.config(state=commit_tag_state) + 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_state) + mf.refresh_tags_button.config(state=commit_tag_branch_state) if hasattr(mf, 'create_tag_button'): - mf.create_tag_button.config(state=commit_tag_state) + mf.create_tag_button.config(state=commit_tag_branch_state) if hasattr(mf, 'checkout_tag_button'): - mf.checkout_tag_button.config(state=commit_tag_state) + mf.checkout_tag_button.config(state=commit_tag_branch_state) + + # Update current branch display if repo not ready + if not is_ready: + mf.set_current_branch_display("") def open_gitignore_editor(self): """Opens the modal editor window for .gitignore.""" self.logger.info("--- Action Triggered: Edit .gitignore ---") svn_path = self._get_and_validate_svn_path("Edit .gitignore") - if not svn_path: return + if not svn_path: return # Stop if path invalid gitignore_path = os.path.join(svn_path, ".gitignore") self.logger.debug(f"Target .gitignore path: {gitignore_path}") try: - # Create and run the modal editor + # Create and run the modal editor window editor = GitignoreEditorWindow(self.master, gitignore_path, self.logger) self.logger.debug("Gitignore editor finished.") # After window closes except Exception as e: @@ -426,7 +419,7 @@ class GitSvnSyncApp: # Save settings before action if not self.ui_save_settings(): - self.logger.warning("Prepare SVN: Failed save settings first.") + self.logger.warning("Prepare SVN: Failed to save settings first.") # Ask user? # Delegate execution to ActionHandler @@ -442,7 +435,7 @@ class GitSvnSyncApp: 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) + self.update_svn_status_indicator(svn_path) # Update state except Exception as e: self.logger.exception(f"Unexpected error during preparation: {e}") self.main_frame.show_error("Error", f"Unexpected error:\n{e}") @@ -453,7 +446,9 @@ class GitSvnSyncApp: """Callback for 'Create Bundle' button.""" self.logger.info("--- Action Triggered: Create Git Bundle ---") 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 # Validate inputs svn_path = self._get_and_validate_svn_path("Create Bundle") @@ -461,7 +456,9 @@ class GitSvnSyncApp: usb_path = self._get_and_validate_usb_path("Create Bundle") if not usb_path: return bundle_name = self.main_frame.bundle_name_entry.get().strip() - if not bundle_name: self.main_frame.show_error("Input Error", "Bundle name empty."); return + if not bundle_name: + self.main_frame.show_error("Input Error", "Bundle name empty.") + return # Ensure .bundle extension if not bundle_name.lower().endswith(".bundle"): bundle_name += ".bundle" @@ -471,50 +468,55 @@ class GitSvnSyncApp: # 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", "") try: + # Parse exclusions needed for backup step within action handler excluded_ext, excluded_dir = self._parse_exclusions(profile) - except ValueError as e: # Catch parsing errors - self.main_frame.show_error("Config Error", str(e)); return + except ValueError as e: + self.main_frame.show_error("Config Error", str(e)) + return # Save settings before action if not self.ui_save_settings(): - self.logger.warning("Create Bundle: Failed save settings first.") + self.logger.warning("Create Bundle: Failed to save settings first.") # Ask user? # Delegate execution to ActionHandler try: created_path = self.action_handler.execute_create_bundle( - svn_path, bundle_full_path, profile, backup_needed, backup_dir, - commit_needed, commit_msg, excluded_ext, excluded_dir + 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 ) + # 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 from backup, commit, or bundle creation - self.logger.error(f"Create bundle process error: {e}", exc_info=True) - # Provide specific message based on exception type if possible - if isinstance(e, IOError) and "backup" in str(e).lower(): - self.main_frame.show_error("Backup Error", f"Backup failed:\n{e}") - elif isinstance(e, GitCommandError) and "commit" in str(e).lower(): - self.main_frame.show_error("Commit Error", f"Commit failed:\n{e}") - elif isinstance(e, GitCommandError) and "bundle" in str(e).lower(): - self.main_frame.show_error("Bundle Error", f"Bundle creation failed:\n{e}") - else: # General/unexpected error - self.main_frame.show_error("Error", f"Failed:\n{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}") def ui_fetch_bundle(self): """Callback for 'Fetch Bundle' button.""" self.logger.info("--- Action Triggered: Fetch from Git Bundle ---") 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 # Validate inputs svn_path = self._get_and_validate_svn_path("Fetch Bundle") @@ -522,48 +524,62 @@ class GitSvnSyncApp: usb_path = self._get_and_validate_usb_path("Fetch Bundle") if not usb_path: return bundle_name = self.main_frame.bundle_updated_name_entry.get().strip() - if not bundle_name: self.main_frame.show_error("Input Error", "Fetch name empty."); return + if not bundle_name: + self.main_frame.show_error("Input Error", "Fetch bundle name empty.") + return bundle_full_path = os.path.join(usb_path, bundle_name) if not os.path.isfile(bundle_full_path): - self.main_frame.show_error("Error", f"Bundle not found:\n{bundle_full_path}"); return + self.main_frame.show_error("Error", f"Bundle not found:\n{bundle_full_path}") + 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", "") try: excluded_ext, excluded_dir = self._parse_exclusions(profile) except ValueError as e: - self.main_frame.show_error("Config Error", str(e)); return + self.main_frame.show_error("Config Error", str(e)) + return # Save settings before action if not self.ui_save_settings(): - self.logger.warning("Fetch Bundle: Failed save settings first.") + self.logger.warning("Fetch Bundle: Failed to save settings first.") # Ask user? # Delegate execution to ActionHandler try: self.action_handler.execute_fetch_bundle( - svn_path, bundle_full_path, profile, backup_needed, backup_dir, - excluded_ext, excluded_dir + 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 ) # 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 + self.refresh_branch_list() + self.update_current_branch_display() + self.refresh_tag_list() except GitCommandError as e: # Handle specific Git errors like merge conflicts self.logger.error(f"Fetch/merge error: {e}", exc_info=False) if "merge conflict" in str(e).lower(): self.main_frame.show_error( "Merge Conflict", - f"Merge conflict occurred.\nResolve manually in:\n{svn_path}" - f"\nThen run 'git add .' and 'git commit'." + f"Conflict occurred.\nResolve manually in:\n{svn_path}\nThen commit." ) else: - self.main_frame.show_error("Fetch/Merge Error", f"Failed:\n{e}") + self.main_frame.show_error("Error", f"Fetch/Merge Failed:\n{e}") except Exception as e: # Handle other errors (backup, unexpected) - self.logger.error(f"Error during fetch process: {e}", exc_info=True) + 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: @@ -574,42 +590,62 @@ class GitSvnSyncApp: """Callback for 'Backup Now' button.""" self.logger.info("--- Action Triggered: Manual Backup ---") profile = self.main_frame.profile_var.get() - if not profile: self.main_frame.show_error("Error", "No profile."); return + if not profile: + self.main_frame.show_error("Error", "No profile selected.") + return + + # Validate paths svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})") if not svn_path: return backup_dir = self.main_frame.backup_dir_var.get().strip() - if not backup_dir: self.main_frame.show_error("Error", "Backup dir empty."); return - # Check/create backup dir here in the UI layer before calling handler? - if not os.path.isdir(backup_dir): - if self.main_frame.ask_yes_no("Create Dir?", f"Create dir:\n{backup_dir}?"): - try: os.makedirs(backup_dir, exist_ok=True) - except OSError as e: self.main_frame.show_error("Error", f"Cannot create:\n{e}"); return - else: return # User cancelled creation + if not backup_dir: + self.main_frame.show_error("Backup Error", "Backup directory empty.") + return - # Parse exclusions before calling handler + # 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?" + if self.main_frame.ask_yes_no("Create Directory?", create_q): + try: + os.makedirs(backup_dir, exist_ok=True) + except OSError as e: + self.main_frame.show_error("Error", f"Cannot create dir:\n{e}") + return # Stop if cannot create dir + else: + self.logger.info("User cancelled backup dir creation.") + return # Stop if user cancels creation + + # Parse exclusions needed for backup handler try: excluded_ext, excluded_dir = self._parse_exclusions(profile) except ValueError as e: - self.main_frame.show_error("Config Error", str(e)); return + self.main_frame.show_error("Config Error", str(e)) + return - # Save settings first + # Save settings first (especially backup dir/exclusions) if not self.ui_save_settings(): - if not self.main_frame.ask_yes_no("Warning", "Could not save settings.\nContinue backup anyway?"): - self.logger.warning("Manual backup aborted."); return + if not self.main_frame.ask_yes_no( + "Warning", "Could not save settings.\nContinue backup anyway?" + ): + self.logger.warning("Manual backup aborted by user.") + return - # Delegate to BackupHandler + # Delegate backup creation to BackupHandler try: backup_path = self.backup_handler.create_zip_backup( svn_path, backup_dir, profile, excluded_ext, excluded_dir ) - # Check if backup_path is returned (success) + # Show success message if path is returned if backup_path: - self.main_frame.show_info("Backup Complete", - f"Backup created:\n{backup_path}") + 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).") + self.main_frame.show_error("Backup Error", + "Backup failed (unknown reason).") except Exception as e: + # Handle any exception raised by backup handler self.logger.error(f"Manual backup failed: {e}", exc_info=True) self.main_frame.show_error("Backup Error", f"Failed:\n{e}") @@ -620,37 +656,63 @@ class GitSvnSyncApp: svn_path = self._get_and_validate_svn_path("Manual Commit") if not svn_path: return + # Get commit message from GUI commit_msg = self.main_frame.commit_message_var.get().strip() if not commit_msg: - self.logger.warning("Manual commit blocked: Message empty.") self.main_frame.show_error("Commit Error", "Commit message empty.") return - # Save settings first? Optional for commit. + # Save settings first? Optional. if not self.ui_save_settings(): self.logger.warning("Manual Commit: Could not save settings.") # Ask user? - # Delegate commit execution to ActionHandler + # Delegate execution to ActionHandler try: commit_made = self.action_handler.execute_manual_commit( svn_path, commit_msg ) if commit_made: - self.main_frame.show_info("Commit Successful", "Changes committed.") - # Clear message field after success? + self.main_frame.show_info("Success", "Changes committed.") + # Optionally clear message field # self.main_frame.commit_message_var.set("") else: - self.main_frame.show_info("Nothing to Commit", "No changes detected.") + self.main_frame.show_info("Info", "No changes to commit.") except (GitCommandError, ValueError) as e: self.logger.error(f"Manual commit failed: {e}") self.main_frame.show_error("Commit Error", f"Failed:\n{e}") except Exception as e: - self.logger.exception(f"Unexpected error during manual commit: {e}") + self.logger.exception(f"Unexpected commit error: {e}") self.main_frame.show_error("Error", f"Unexpected commit error:\n{e}") + # --- Tag Management Callbacks --- + def refresh_tag_list(self): + """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([]) + 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) + if hasattr(self, 'main_frame'): + self.main_frame.update_tag_list(tags_data) + self.logger.info(f"Tag list updated ({len(tags_data)} tags).") + except Exception as e: + self.logger.error(f"Failed refresh tags: {e}", exc_info=True) + self.main_frame.show_error("Error", f"Could not refresh tags:\n{e}") + if hasattr(self, 'main_frame'): + self.main_frame.update_tag_list([]) + + def ui_create_tag(self): """Callback for 'Create Tag' button.""" self.logger.info("--- Action Triggered: Create Tag ---") @@ -659,7 +721,7 @@ class GitSvnSyncApp: profile = self.main_frame.profile_var.get() if not profile: self.main_frame.show_error("Error", "No profile."); return - # Get commit message from GUI (needed for potential pre-commit) + # Get commit message from GUI (needed by action handler for pre-commit) commit_msg = self.main_frame.commit_message_var.get().strip() # Save settings before action @@ -667,28 +729,27 @@ class GitSvnSyncApp: self.logger.warning("Create Tag: Could not save settings first.") # Ask user? - # Open Dialog first to get tag name/message + # --- Open Dialog 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 by user in dialog.") - return # User cancelled dialog + 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 (including potential pre-commit) to ActionHandler + # --- Delegate Execution to ActionHandler --- try: success = self.action_handler.execute_create_tag( svn_path, commit_msg, tag_name, tag_message ) - # ActionHandler raises errors if commit/tag fail - if success: # Should always be true if no exception + # execute_create_tag raises exceptions on failure + if success: # Should be true if no exception self.logger.info(f"Tag '{tag_name}' created successfully.") self.main_frame.show_info("Success", f"Tag '{tag_name}' created.") - self.refresh_tag_list() # Update list after successful creation + self.refresh_tag_list() # Update list except ValueError as e: # Catch specific errors like "commit message required" self.logger.error(f"Tag creation validation failed: {e}") self.main_frame.show_error("Tag Error", str(e)) @@ -713,23 +774,24 @@ class GitSvnSyncApp: self.logger.info(f"Attempting checkout for tag: {selected_tag}") # Confirmation dialog first - confirm_msg = (f"Checkout tag '{selected_tag}'?\n\n" - f"WARNINGS:\n- Files WILL BE OVERWRITTEN.\n- NO backup created.\n" - f"- Enters 'detached HEAD' state.") - if not self.main_frame.ask_yes_no("Confirm Checkout", confirm_msg): + msg = (f"Checkout tag '{selected_tag}'?\n\n" + f"WARNINGS:\n- Files WILL BE OVERWRITTEN.\n- NO backup created.\n" + f"- Enters 'detached HEAD' state.") + if not self.main_frame.ask_yes_no("Confirm Checkout", msg): self.logger.info("Tag checkout cancelled."); return - # Save settings before action (optional) + # Save settings before action? Optional. if not self.ui_save_settings(): - self.logger.warning("Checkout Tag: Could not save settings.") + self.logger.warning("Checkout Tag: Could not save profile settings.") # Delegate execution to ActionHandler try: success = self.action_handler.execute_checkout_tag(svn_path, selected_tag) if success: - self.main_frame.show_info("Checkout Successful", + self.main_frame.show_info("Success", f"Checked out tag '{selected_tag}'.\n\nNOTE: In 'detached HEAD'.") - # TODO: Update UI state for detached HEAD? + # Update branch display after 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)) @@ -741,142 +803,268 @@ class GitSvnSyncApp: self.main_frame.show_error("Error", f"Unexpected checkout error:\n{e}") - # --- Tag Management (Direct Call/Wrapper) --- - def refresh_tag_list(self): - """Refreshes tag list in GUI. Called by button or after profile load.""" - 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([]) - 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 tags and update GUI + # --- 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")): + if hasattr(self, 'main_frame'): self.main_frame.update_branch_list([]) + return # Repo not ready or path invalid try: - tags_data = self.git_commands.list_tags(svn_path) # List of (name, subject) - if hasattr(self, 'main_frame'): self.main_frame.update_tag_list(tags_data) - self.logger.info(f"Tag list updated ({len(tags_data)} tags).") + # 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) + self.logger.info(f"Branch list updated ({len(branches)} branches).") except Exception as e: - self.logger.error(f"Failed refresh tags: {e}", exc_info=True) - self.main_frame.show_error("Error", f"Could not refresh tags:\n{e}") - if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([]) + self.logger.error(f"Failed refresh branches: {e}", exc_info=True) + self.main_frame.show_error("Error", f"Could not refresh branches:\n{e}") + if hasattr(self, 'main_frame'): + self.main_frame.update_branch_list([]) + + + def update_current_branch_display(self): + """Gets the current branch and updates the display label.""" + self.logger.debug("Updating current branch display...") + svn_path = self._get_and_validate_svn_path("Update Branch Display") + current_branch = "" # Default value + + # Only attempt if path is valid and repo is prepared + 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 + except Exception as e: + self.logger.error(f"Failed to get current branch: {e}") + current_branch = "" + + # Update the GUI label + if hasattr(self, 'main_frame'): + self.main_frame.set_current_branch_display(current_branch) + + + def ui_create_branch(self): + """Callback for 'Create Branch' button.""" + self.logger.info("--- Action Triggered: Create Branch ---") + svn_path = self._get_and_validate_svn_path("Create Branch") + if not svn_path: return + + # Use custom dialog to get 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}'") + # Save settings? Optional. + + # Delegate execution + try: + # Assuming ActionHandler has execute_create_branch + 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. + except (GitCommandError, ValueError) as e: + self.main_frame.show_error("Error", f"Could not create branch:\n{e}") + except Exception as e: + self.logger.exception(f"Unexpected error creating branch: {e}") + self.main_frame.show_error("Error", f"Unexpected error:\n{e}") + + + def ui_switch_branch(self): + """Callback for 'Switch to Selected Branch' button.""" + self.logger.info("--- Action Triggered: Switch Branch ---") + svn_path = self._get_and_validate_svn_path("Switch Branch") + if not svn_path: return + + selected_branch = self.main_frame.get_selected_branch() # Gets name + if not selected_branch: + self.main_frame.show_error("Error", "Select a branch."); return + + 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}'.") + return + + self.logger.info(f"Attempting switch to branch: {selected_branch}") + # Save settings? Optional. + + # Delegate execution (ActionHandler should check for uncommitted changes) + try: + # Assuming ActionHandler has execute_switch_branch + success = self.action_handler.execute_switch_branch(svn_path, selected_branch) + if success: + self.main_frame.show_info("Success", f"Switched to branch '{selected_branch}'.") + self.update_current_branch_display() # Update label + self.refresh_branch_list() # Update highlight in list + # else: Handler should raise error + except ValueError as e: # Catch specific errors like uncommitted changes + self.main_frame.show_error("Switch Blocked", str(e)) + except GitCommandError as e: # Catch errors like branch not found + self.main_frame.show_error("Error", f"Could not switch branch:\n{e}") + except Exception as e: + self.logger.exception(f"Unexpected error switching branch: {e}") + self.main_frame.show_error("Error", f"Unexpected error:\n{e}") + + + def ui_delete_branch(self): + """Callback for 'Delete Selected Branch' button.""" + self.logger.info("--- Action Triggered: Delete Branch ---") + svn_path = self._get_and_validate_svn_path("Delete Branch") + if not svn_path: return + + selected_branch = self.main_frame.get_selected_branch() # Gets name + if not selected_branch: + self.main_frame.show_error("Error", "Select a branch."); return + + # Prevent deleting current branch + 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 + if selected_branch in ["main", "master"]: + self.main_frame.show_error("Error", f"Cannot delete '{selected_branch}' branch.") + return + + # Confirmation + msg = f"Delete local branch '{selected_branch}'?\nThis cannot be undone easily!" + if not self.main_frame.ask_yes_no("Confirm Delete Branch", msg): + self.logger.info("Branch deletion cancelled."); return + + self.logger.info(f"Attempting delete branch: {selected_branch}") + # Save settings? Unlikely needed. + + # Delegate execution + try: + # Assuming ActionHandler has execute_delete_branch + success = self.action_handler.execute_delete_branch(svn_path, selected_branch) + 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?) + except GitCommandError as e: + # Handle specific errors, e.g., branch not fully merged (-d fails) + 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 + 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 + except Exception as force_e: + self.logger.error(f"Force delete failed: {force_e}", exc_info=True) + self.main_frame.show_error("Error", f"Force delete failed:\n{force_e}") + else: + self.logger.info("Force delete cancelled.") + else: + # Other Git errors + self.main_frame.show_error("Error", f"Could not delete branch:\n{e}") + except Exception as e: + self.logger.exception(f"Unexpected error deleting branch: {e}") + self.main_frame.show_error("Error", f"Unexpected error:\n{e}") # --- GUI State Utilities --- def _clear_and_disable_fields(self): - """Clears relevant GUI fields and disables most buttons.""" + """Clears fields and disables most buttons.""" if hasattr(self, 'main_frame'): mf = self.main_frame - # Clear Repo frame fields mf.svn_path_entry.delete(0, tk.END) mf.usb_path_entry.delete(0, tk.END) mf.bundle_name_entry.delete(0, tk.END) mf.bundle_updated_name_entry.delete(0, tk.END) - # Clear Commit/Tag frame fields mf.commit_message_var.set("") mf.autocommit_var.set(False) - mf.update_tag_list([]) # Clear tag listbox - # Reset indicator and dependent buttons (handles state-based disabling) + 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 action buttons explicitly + # Disable general buttons self._disable_general_buttons() - self.logger.debug("GUI fields cleared/reset. Buttons disabled.") + self.logger.debug("GUI fields cleared/reset.") def _disable_general_buttons(self): """Disables buttons generally requiring only a loaded profile.""" if hasattr(self, 'main_frame'): - # List of general action button attribute names in main_frame - button_names = [ - 'create_bundle_button', 'fetch_bundle_button', - 'manual_backup_button', 'save_settings_button' - ] - for name in button_names: - button = getattr(self.main_frame, name, None) - if button: - button.config(state=tk.DISABLED) + 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) def _enable_function_buttons(self): - """ - Enables general action buttons. State-dependent buttons rely on - update_svn_status_indicator for their state. - """ + """Enables general buttons. State buttons rely on status update.""" if hasattr(self, 'main_frame'): - general_state = tk.NORMAL - # List of general action button attribute names - button_names = [ - 'create_bundle_button', 'fetch_bundle_button', - 'manual_backup_button', 'save_settings_button' - ] - 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 path status - # This updates Prepare, EditGitignore, Commit/Tag widget states - current_svn_path = "" - if hasattr(self.main_frame, 'svn_path_entry'): - current_svn_path = self.main_frame.svn_path_entry.get() - self.update_svn_status_indicator(current_svn_path) + 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()) self.logger.debug("General buttons enabled. State buttons updated.") def show_fatal_error(self, message): """Shows a fatal error message.""" try: - # Determine parent window safely - parent = None - if hasattr(self, 'master') and self.master and self.master.winfo_exists(): - parent = self.master + parent = self.master if hasattr(self, 'master') and \ + self.master and self.master.winfo_exists() else None messagebox.showerror("Fatal Error", message, parent=parent) - except 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}") + except tk.TclError: print(f"FATAL ERROR: {message}") + except Exception as e: print(f"FATAL ERROR (+GUI error {e}): {message}") + # --- Application Entry Point --- def main(): """Main function: Creates Tkinter root and runs the application.""" root = tk.Tk() - # Adjust min size for the new layout - root.minsize(700, 700) # Adjusted min height - app = None # Initialize app variable + # Adjust min size for the new layout (may need further tweaking) + root.minsize(750, 800) # Increased height significantly + app = None try: app = GitSvnSyncApp(root) # Start main loop only if initialization likely succeeded if hasattr(app, 'main_frame') and app.main_frame: root.mainloop() else: - # Initialization failed before GUI setup could complete - print("Application initialization failed, exiting.") - # Ensure window closes if init failed but window was created - if root and root.winfo_exists(): - root.destroy() + print("App init failed before GUI setup.") + if root and root.winfo_exists(): root.destroy() except Exception as e: - # Catch-all for unexpected errors during startup or main loop - logging.exception("Fatal error during application startup or main loop.") - # Try showing message box, fallback to print + logging.exception("Fatal error during startup/mainloop.") try: - parent_window = root if root and root.winfo_exists() else None - messagebox.showerror("Fatal Error", - f"Application failed unexpectedly:\n{e}", - parent=parent_window) - except Exception as msg_e: - print(f"FATAL ERROR (GUI error: {msg_e}): App failed:\n{e}") + 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}") finally: - # Log application exit regardless of success or failure logging.info("Application exiting.") if __name__ == "__main__": - # Set up basic logging configuration immediately at startup + # Setup basic logging immediately log_format = "%(asctime)s - %(levelname)s - [%(module)s:%(funcName)s] - %(message)s" logging.basicConfig(level=logging.INFO, format=log_format) main() \ No newline at end of file diff --git a/git_commands.py b/git_commands.py index aea5c2b..9bd23d7 100644 --- a/git_commands.py +++ b/git_commands.py @@ -2,749 +2,415 @@ import os import subprocess import logging -import re # Ensure re is imported +import re class GitCommandError(Exception): - """ - Custom exception for handling Git command errors. - Includes the original command and error details if available. - """ + """ Custom exception for Git command errors. """ 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): - """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 - + 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 class GitCommands: - """ - Manages Git commands execution, logging, and error handling. - Includes tag management functionalities. - """ + """ Manages Git commands execution. """ def __init__(self, logger): - """ - 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.") + """ Initializes with a logger. """ + if not isinstance(logger, logging.Logger): raise ValueError("Valid logger required.") self.logger = logger def log_and_execute(self, command, working_directory, check=True): - """ - Executes a command within a specific working directory, logs it, - and handles errors. - - Args: - command (list): List representing the command and its arguments. - working_directory (str): Path to the directory for command execution. - check (bool, optional): Raise error on non-zero exit code if True. - Defaults to True. - - Returns: - subprocess.CompletedProcess: The result object from subprocess.run. - - Raises: - GitCommandError: If path invalid, command fails (and check=True), etc. - ValueError: If working_directory is None or empty. - """ - # Ensure all parts of command are strings for logging and execution - safe_command = [str(part) for part in command] - command_str = ' '.join(safe_command) - log_message = f"Executing: {command_str}" - self.logger.debug(log_message) - - # Validate working directory - if not working_directory: - msg = "Working directory cannot be None or empty." - self.logger.error(msg) - raise ValueError(msg) - + """ 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): - msg = f"Working directory does not exist or is not a directory: {abs_path}" - self.logger.error(msg) - # Include command context in the error - raise GitCommandError(msg, command=safe_command) - - cwd = abs_path - self.logger.debug(f"Working directory: {cwd}") - + 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: - # Platform-specific setup to hide console window on Windows - startupinfo = None - creationflags = 0 # Default creation flags + startupinfo = None; creationflags = 0 if os.name == 'nt': - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo = subprocess.STARTUPINFO(); startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE - # Optional: Use CREATE_NO_WINDOW if std I/O redirection not needed - # creationflags = subprocess.CREATE_NO_WINDOW - - # Execute the command result = subprocess.run( - safe_command, # Use the validated command list - cwd=cwd, - capture_output=True, - text=True, - check=check, - encoding='utf-8', - errors='replace', # Handle potential decoding errors - startupinfo=startupinfo, - creationflags=creationflags - ) - - # Log stdout and stderr clearly - stdout_log = result.stdout.strip() if result.stdout else "" - stderr_log = result.stderr.strip() if result.stderr else "" - - # Log success with output details - self.logger.info( - f"Command successful. Output:\n" - f"--- stdout ---\n{stdout_log}\n" - f"--- stderr ---\n{stderr_log}\n---" + 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: - # 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. - - Raises: - GitCommandError: If command fails or path invalid. - ValueError: If working_directory is None or empty. - """ - # Normalize bundle path for consistency across OS - 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: - # Execute command, allow non-zero exit for specific warning - # Use check=False and manually check return code and stderr - result = self.log_and_execute( - command, - working_directory, - check=False - ) - - # Check for non-fatal warnings vs actual errors - 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: Repository at " - f"'{working_directory}' has no commits to bundle." - ) - # This is not treated as an error, do not raise - else: - # An actual error occurred during bundle creation - error_msg = ( - f"Git bundle command failed with return code " - f"{result.returncode}." - ) - # log_and_execute already logged details in case of failure - # Raise specific error here to signal failure - raise GitCommandError( - error_msg, - command=command, - stderr=result.stderr - ) - # Check if file exists and has size even if return code was 0 - elif not os.path.exists(normalized_bundle_path) or \ - os.path.getsize(normalized_bundle_path) == 0: - self.logger.warning( - f"Bundle command returned success, but file " - f"'{normalized_bundle_path}' is missing or empty." - ) - # Consider if this should be an error depending on requirements - else: - self.logger.info( - f"Git bundle created successfully: '{normalized_bundle_path}'." - ) - - except (GitCommandError, ValueError) as e: - # Log context and re-raise known errors - self.logger.error( - f"Failed to create Git bundle for repo " - f"'{working_directory}'. Reason: {e}" - ) - raise - except Exception as e: - # Catch unexpected errors - self.logger.exception( - f"Unexpected error during Git bundle creation for " - f"'{working_directory}': {e}" - ) - raise GitCommandError( - f"Unexpected error creating bundle: {e}", - command=command - ) from e - - - def fetch_from_git_bundle(self, working_directory, bundle_path): - """ - Fetches changes from a Git bundle file into the specified local - repository and merges them. - - Args: - working_directory (str): Path to the local Git repository. - bundle_path (str): Path to the Git bundle file to fetch from. - - Raises: - GitCommandError: If fetch or merge fails, or paths are invalid. - ValueError: If working_directory is None or empty. - """ - # Normalize bundle path - normalized_bundle_path = os.path.normpath(bundle_path) - normalized_bundle_path = normalized_bundle_path.replace("\\", "/") - self.logger.info( - f"Attempting to fetch from bundle '{normalized_bundle_path}' " - f"into '{working_directory}'" - ) - - # Define commands - fetch_command = ["git", "fetch", normalized_bundle_path] - # Merge strategy: No fast-forward, create merge commit if needed - merge_command = ["git", "merge", "FETCH_HEAD", "--no-ff"] - - try: - # 1. Fetch changes from the bundle - self.logger.debug(f"Executing fetch command...") - # Use check=True to ensure fetch succeeds or raises error - self.log_and_execute(fetch_command, working_directory, check=True) - self.logger.info("Successfully fetched from Git bundle.") - - # 2. Merge the fetched changes - self.logger.debug(f"Executing merge command...") - # Use check=False for merge, as conflicts return non-zero code - merge_result = self.log_and_execute( - merge_command, - working_directory, - check=False - ) - - # Analyze merge result - stdout_log = merge_result.stdout.strip() if merge_result.stdout else "" - stderr_log = merge_result.stderr.strip() if merge_result.stderr else "" - - if merge_result.returncode == 0: - # Merge was successful or nothing to merge - if "already up to date" in stdout_log.lower(): - self.logger.info("Repository was already up-to-date.") - else: - self.logger.info("Successfully merged fetched changes.") - else: - # Merge likely failed due to conflicts or other issues - output_lower = (stderr_log + stdout_log).lower() - if "conflict" in output_lower: - conflict_msg = ( - f"Merge conflict occurred after fetching from bundle " - f"'{os.path.basename(bundle_path)}'. Please resolve " - f"conflicts manually in '{working_directory}' and commit." - ) - self.logger.error(conflict_msg) - # Raise a specific error indicating conflict for the caller - raise GitCommandError( - conflict_msg, - command=merge_command, - stderr=merge_result.stderr - ) - else: - # Other merge error - error_msg = ( - f"Merge command failed unexpectedly after fetch " - f"(return code {merge_result.returncode})." - ) - self.logger.error(error_msg) - # Raise specific error, details logged by log_and_execute - raise GitCommandError( - error_msg, - command=merge_command, - stderr=merge_result.stderr - ) - - except (GitCommandError, ValueError) as e: - # Log context and re-raise known errors - self.logger.error( - f"Error during fetch/merge for repo '{working_directory}' " - f"from bundle '{bundle_path}'. Reason: {e}" - ) - raise - except Exception as e: - # Catch unexpected errors - self.logger.exception( - f"Unexpected error during fetch/merge for '{working_directory}': {e}" - ) - raise GitCommandError(f"Unexpected error during fetch/merge: {e}") from 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 Git: initializes repo (if needed) and - ensures .gitignore ignores '.svn'. - - Args: - working_directory (str): The path to the directory to prepare. - - Raises: - GitCommandError: If command fails, file ops fail, path invalid. - ValueError: If working_directory is None or empty. - """ - self.logger.info(f"Preparing directory for Git: '{working_directory}'") - - # Basic path validation - if not working_directory: - raise ValueError("Working directory cannot be None or empty.") - if not os.path.isdir(working_directory): - raise GitCommandError(f"Directory does not exist: {working_directory}") - - gitignore_path = os.path.join(working_directory, ".gitignore") - git_dir_path = os.path.join(working_directory, ".git") - - # 1. Initialize Git repository if it doesn't exist - if not os.path.exists(git_dir_path): - self.logger.info("No existing Git repository found. Initializing...") - try: - init_command = ["git", "init"] - 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 preparation 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): - self.logger.info("'.gitignore' not found. Creating.") - content_to_write = f"{svn_ignore_entry}\n" - needs_write = True - else: - # Check if .svn is already ignored - try: - with open(gitignore_path, "r", encoding='utf-8') as f: - lines = f.readlines() - # Check ignores .svn directory or files within it - is_ignored = any( - line.strip() == svn_ignore_entry or \ - line.strip().startswith(svn_ignore_entry + '/') - for line in lines - ) - - if not is_ignored: - self.logger.info(f"'{svn_ignore_entry}' not found. Appending.") - # Prepare content to append, ensuring newline separation - current_content = "".join(lines) - 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 # Need to append - else: - self.logger.info(f"'{svn_ignore_entry}' already ignored.") - - except IOError as e: - self.logger.warning(f"Could not read '.gitignore': {e}.") - # Cannot verify, maybe attempt write? Safer to leave alone. - # needs_write = True # Or False, depending on desired safety - - # Write to file only if necessary - if needs_write: - # Append or write new file - mode = 'a' if os.path.exists(gitignore_path) else 'w' - try: - # Use newline='\n' for consistent line endings - 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 (git add .) and commits them. - - Args: - working_directory (str): Path to the local Git repository. - message (str): The commit message. Defaults to "Autocommit". - - Returns: - bool: True if a commit was made, False if no changes. - - Raises: - GitCommandError: If staging or commit fails unexpectedly. - ValueError: If working_directory is None or empty. - """ - self.logger.info( - f"Attempting commit in '{working_directory}' with msg: '{message}'" - ) + """ 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: - # 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...") - # check=False because 'nothing to commit' returns non-zero - result = self.log_and_execute( - commit_command, - working_directory, - check=False - ) - - # Analyze commit result carefully - stdout_lower = result.stdout.lower() if result.stdout else "" - stderr_lower = result.stderr.lower() if result.stderr else "" - - if result.returncode == 0: - # Commit successful, new commit created - self.logger.info(f"Commit successful in '{working_directory}'.") - return True - # Check variations of 'nothing to commit' messages - elif "nothing to commit" in stdout_lower or \ - "no changes added to commit" in stdout_lower or \ - "nothing added to commit" in stdout_lower or \ - (result.returncode == 1 and not stderr_lower and not stdout_lower): - # Common non-error case: no changes found - self.logger.info("No changes to commit.") - return False + 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: - # An unexpected error occurred during the commit attempt - error_msg = ( - f"Commit command failed unexpectedly " - f"(return code {result.returncode})." - ) - # Details were logged by log_and_execute if check=False - raise GitCommandError( - error_msg, - command=commit_command, - stderr=result.stderr - ) + 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.") - except (GitCommandError, ValueError) as e: - self.logger.error(f"Error during staging or commit process: {e}") - raise # Re-raise the specific error caught - except Exception as e: - # Catch any other unexpected error during the process - self.logger.exception(f"Unexpected error during staging/commit: {e}") - raise GitCommandError(f"Unexpected commit error: {e}") from e + 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_status_has_changes(self, working_directory): + 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~^:?*[\\]+(?" # Indicate error state except (GitCommandError, ValueError) as e: - self.logger.error(f"Error checking Git status: {e}") - raise # Re-raise specific error + self.logger.error(f"Error getting current branch: {e}") + return "" except Exception as e: - # Catch unexpected errors - self.logger.exception(f"Unexpected error checking Git status: {e}") - raise GitCommandError(f"Unexpected status error: {e}") from e + self.logger.exception(f"Unexpected error getting current branch: {e}") + return "" - - def list_tags(self, working_directory): + def list_branches(self, working_directory): """ - Lists Git tags with subjects, sorted by creation date (desc). + Lists local Git branches. Args: working_directory (str): Path to the local Git repository. Returns: - list: List of tuples `(tag_name, tag_subject)`. Empty on error. + list: A list of local branch names (str). Empty on error. """ - self.logger.info(f"Listing tags with subjects in '{working_directory}'...") - # Format: - format_string = "%(refname:short)%09%(contents:subject)" - command = ["git", "tag", "--list", f"--format={format_string}", "--sort=-creatordate"] - tags_with_subjects = [] + self.logger.info(f"Listing local branches in '{working_directory}'...") + cmd = ["git", "branch", "--list", "--no-color"] + branches = [] 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: - # Split only on the first tab - parts = line_stripped.split('\t', 1) - tag_name = parts[0].strip() - # Handle cases where subject might be missing - tag_subject = parts[1].strip() if len(parts) > 1 else "(No subject)" - tags_with_subjects.append((tag_name, tag_subject)) - - self.logger.info(f"Found {len(tags_with_subjects)} tags with subjects.") - self.logger.debug(f"Tags found: {tags_with_subjects}") - return tags_with_subjects + 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.") + self.logger.debug(f"Branches: {branches}") + return branches except (GitCommandError, ValueError) as e: - # Log specific error but return empty list for GUI handling - self.logger.error(f"Error listing tags with subjects: {e}") - return [] # Return empty list on known errors + self.logger.error(f"Error listing branches: {e}") + return [] except Exception as e: - # Log unexpected errors and return empty list - self.logger.exception(f"Unexpected error listing tags with subjects: {e}") + self.logger.exception(f"Unexpected error listing branches: {e}") return [] - - def create_tag(self, working_directory, tag_name, message): + def create_branch(self, working_directory, branch_name, start_point=None): """ - Creates a new annotated Git tag. + Creates a new local Git branch. Args: working_directory (str): Path to the local Git repository. - tag_name (str): The name for the new tag (e.g., "v1.0"). - message (str): The annotation message for the tag. + 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 tag invalid, exists, or command fails. - ValueError: If args invalid. - """ - self.logger.info( - f"Creating tag '{tag_name}' in '{working_directory}' " - f"with message: '{message}'" - ) - - # Argument validation - if not tag_name: - raise ValueError("Tag name cannot be empty.") - if not message: - raise ValueError("Tag message cannot be empty.") - - # Git tag name validation regex (based on git check-ref-format) - # Avoids leading/trailing '.', '..', '/.', '.lock', invalid chars - invalid_tag_pattern = \ - r"^(?![./]|.*([./]{2,}|[.]$|\.lock$))[^ \t\n\r\f\v~^:?*[\\]+(?") # For branch display - # Widget Creation + # --- Create GUI Sections --- self._create_profile_frame() - self._create_repo_frame() # Paths/Bundles only - self._create_backup_frame() # Backup settings - self._create_commit_tag_frame() # Commit/Tag UI (modified layout) - self._create_function_frame() # Core Actions + 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.) self._create_log_area() - # Initial State + # --- Initial GUI State --- self._initialize_profile_selection() - self.toggle_backup_dir() + self.toggle_backup_dir() # Set initial state of backup dir entry def _create_profile_frame(self): @@ -321,47 +438,57 @@ class MainFrame(ttk.Frame): self, text="Profile Configuration", padding=(10, 5) ) self.profile_frame.pack(pady=5, fill="x") - self.profile_frame.columnconfigure(1, weight=1) # Dropdown expands + # Allow dropdown column to expand horizontally + self.profile_frame.columnconfigure(1, weight=1) + # Profile Label profile_label = ttk.Label(self.profile_frame, text="Profile:") profile_label.grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + # Profile Dropdown (Combobox) self.profile_dropdown = ttk.Combobox( - self.profile_frame, textvariable=self.profile_var, - state="readonly", width=35, values=self.initial_profile_sections + self.profile_frame, + textvariable=self.profile_var, + state="readonly", + width=35, + values=self.initial_profile_sections ) self.profile_dropdown.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=5) + # Bind selection change to load settings self.profile_dropdown.bind( "<>", lambda e: self.load_profile_settings_callback(self.profile_var.get()) ) + # Trace variable for programmatic changes self.profile_var.trace_add( "write", lambda *a: self.load_profile_settings_callback(self.profile_var.get()) ) + # Save Settings Button self.save_settings_button = ttk.Button( - self.profile_frame, text="Save Settings", + self.profile_frame, + text="Save Settings", command=self.save_profile_callback ) self.save_settings_button.grid(row=0, column=2, sticky=tk.W, padx=(5, 2), pady=5) self.create_tooltip(self.save_settings_button, - "Save settings for the selected profile.") + "Save current settings for selected profile.") + # Add Profile Button self.add_profile_button = ttk.Button( self.profile_frame, text="Add", width=5, command=self.add_profile_callback ) - self.add_profile_button.grid(row=0, column=3, sticky=tk.W, - padx=(2, 0), pady=5) + 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, 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): @@ -370,38 +497,34 @@ class MainFrame(ttk.Frame): self, text="Repository & Bundle Paths", padding=(10, 5) ) self.repo_frame.pack(pady=5, fill="x") - col_label = 0; col_entry = 1; col_button = 2; col_indicator = 3 - self.repo_frame.columnconfigure(col_entry, weight=1) # Entry expands + # Define columns for easier layout management + 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) - # Row 0: SVN Path + # Row 0: SVN Path Entry and Browse Button + Status Indicator svn_label = ttk.Label(self.repo_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.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()) - ) + 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, 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) self.svn_status_indicator = tk.Label( self.repo_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 + # Row 1: USB/Bundle Target Path Entry and Browse Button usb_label = ttk.Label(self.repo_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) @@ -410,173 +533,281 @@ class MainFrame(ttk.Frame): self.repo_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 + # Row 2: Create Bundle Name Entry create_label = ttk.Label(self.repo_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.grid(row=2, column=col_entry, columnspan=2, # Span 2 cols - sticky=tk.EW, padx=5, pady=3) + # 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) - # Row 3: Fetch Bundle Name + # Row 3: Fetch Bundle Name Entry fetch_label = ttk.Label(self.repo_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.grid(row=3, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3) def _create_backup_frame(self): - """Creates the frame for backup configuration including exclusions.""" + """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") - col_label = 0; col_entry = 1; col_button = 2 - self.backup_frame.columnconfigure(col_entry, weight=1) # Entry expands + # Define column indices + col_label = 0 + col_entry = 1 + col_button = 2 + # Configure entry column to expand + self.backup_frame.columnconfigure(col_entry, weight=1) # Row 0: Autobackup Checkbox self.autobackup_checkbox = ttk.Checkbutton( self.backup_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)) + self.autobackup_checkbox.grid(row=0, column=col_label, columnspan=3, sticky=tk.W, padx=5, pady=(5, 0)) - # Row 1: Backup Directory + # Row 1: Backup Directory Entry and Browse Button backup_dir_label = ttk.Label(self.backup_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 ) - self.backup_dir_entry.grid(row=1, column=col_entry, sticky=tk.EW, - padx=5, pady=5) + 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 ) - 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 + # Row 2: Exclude Extensions Entry exclude_label = ttk.Label(self.backup_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 + self.backup_frame, textvariable=self.backup_exclude_extensions_var, width=60 ) - 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)") + # 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)") - # --- MODIFIED: Commit / Tag Management Frame Layout --- - def _create_commit_tag_frame(self): - """Creates the frame for commit settings and tag management.""" - self.commit_tag_frame = ttk.LabelFrame( - self, text="Commit / Tag Management", padding=(10, 5) + # --- 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_tag_frame.pack(pady=5, fill="x") + self.commit_frame.pack(pady=5, fill="x") + # Configure columns + self.commit_frame.columnconfigure(1, weight=1) # Entry expands - # --- Configure grid columns --- - # Col 0: Labels/Checkboxes - # Col 1: Entries / Listbox (Expands) - # Col 2: Commit Button - # Col 3: Vertical Button Column (Tags/Gitignore) (Fixed Width) - self.commit_tag_frame.columnconfigure(1, weight=1) - self.commit_tag_frame.rowconfigure(2, weight=1) # Listbox expands vertically - - # --- Commit Area --- - # Row 0: Autocommit Checkbox (Moved to top) + # Row 0: Autocommit Checkbox (relevant for Create Bundle) self.autocommit_checkbox = ttk.Checkbutton( - self.commit_tag_frame, text="Autocommit before 'Create Bundle'", - variable=self.autocommit_var, state=tk.DISABLED + self.commit_frame, + text="Autocommit before 'Create Bundle' (uses message below)", + variable=self.autocommit_var, + state=tk.DISABLED # State depends on repo readiness ) - self.autocommit_checkbox.grid(row=0, column=0, columnspan=3, # Span 3 cols + self.autocommit_checkbox.grid(row=0, column=0, columnspan=3, # Span all columns sticky="w", padx=5, pady=(5, 3)) self.create_tooltip(self.autocommit_checkbox, - "If checked, commit changes before creating bundle.") + "If checked, commit changes using the message before Create Bundle.") - # Row 1: Commit Message + Commit Button - commit_msg_label = ttk.Label(self.commit_tag_frame, text="Commit Message:") + # Row 1: Commit Message Entry + Manual Commit Button + commit_msg_label = ttk.Label(self.commit_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_tag_frame, textvariable=self.commit_message_var, - width=50, state=tk.DISABLED + self.commit_frame, + textvariable=self.commit_message_var, + width=50, # Adjust width as needed + state=tk.DISABLED # State depends on repo readiness ) self.commit_message_entry.grid(row=1, column=1, sticky="ew", padx=5, pady=3) self.create_tooltip(self.commit_message_entry, - "Message for manual commit or commit before tagging.") + "Message for manual commit or commit before tagging/bundling.") - # New Manual Commit Button + # Manual Commit Button self.commit_button = ttk.Button( - self.commit_tag_frame, text="Commit", width=10, - command=self.manual_commit_callback, state=tk.DISABLED + 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 ) self.commit_button.grid(row=1, column=2, sticky="w", padx=(5, 0), pady=3) self.create_tooltip(self.commit_button, - "Commit staged changes with the provided message.") + "Manually commit staged changes with the provided message.") - # --- Tag Listing Area --- - # Row 2: Listbox + Scrollbar (in own frame for layout) - tag_list_frame = ttk.Frame(self.commit_tag_frame) - # Span label and entry columns - tag_list_frame.grid(row=2, column=0, columnspan=3, sticky="nsew", - padx=5, pady=(10,5)) # Add top padding + + # --- 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 + + # Row 0: Current Branch Display + current_branch_label = ttk.Label(self.branch_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 + ) + # 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, + sticky="ew", padx=5, pady=3) + self.create_tooltip(self.current_branch_display, + "The currently active branch or detached HEAD 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 + + # Row 2: Listbox + Scrollbar (in a subframe) + branch_list_frame = ttk.Frame(self.branch_frame) + # Span across first 3 columns (label, listbox, spacer?) + 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 + ) + self.branch_listbox.grid(row=0, column=0, sticky="nsew") + # Vertical Scrollbar + branch_scrollbar = ttk.Scrollbar( + branch_list_frame, + orient=tk.VERTICAL, + command=self.branch_listbox.yview + ) + branch_scrollbar.grid(row=0, column=1, sticky="ns") + self.branch_listbox.config(yscrollcommand=branch_scrollbar.set) + self.create_tooltip(self.branch_listbox, + "Select a branch for actions (Switch, Delete).") + + # 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)) + + button_width_branch = 18 # Consistent width for vertical buttons + + self.refresh_branches_button = ttk.Button( + branch_button_frame, text="Refresh List", width=button_width_branch, + command=self.refresh_branches_callback, state=tk.DISABLED + ) + self.refresh_branches_button.pack(side=tk.TOP, fill=tk.X, pady=(0, 3)) + self.create_tooltip(self.refresh_branches_button, "Reload branch list.") + + self.create_branch_button = ttk.Button( + branch_button_frame, text="Create Branch...", width=button_width_branch, + command=self.create_branch_callback, state=tk.DISABLED + ) + self.create_branch_button.pack(side=tk.TOP, fill=tk.X, pady=3) + self.create_tooltip(self.create_branch_button, "Create a new local branch.") + + self.switch_branch_button = ttk.Button( + branch_button_frame, text="Switch to Selected", width=button_width_branch, + command=self.switch_branch_callback, state=tk.DISABLED + ) + self.switch_branch_button.pack(side=tk.TOP, fill=tk.X, pady=3) + self.create_tooltip(self.switch_branch_button, "Checkout the selected branch.") + + self.delete_branch_button = ttk.Button( + branch_button_frame, text="Delete Selected", width=button_width_branch, + 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).") + + + # --- 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 + + # Row 0, Column 0: Listbox + Scrollbar Frame + tag_list_frame = ttk.Frame(self.tag_frame) + 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=7, exportselection=False, # Slightly taller - selectmode=tk.SINGLE, font=("Consolas", 9) + tag_list_frame, + height=6, # Initial rows visible + exportselection=False, + selectmode=tk.SINGLE, + font=("Consolas", 9) # Monospaced font for alignment ) 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.") - # --- Tag/Gitignore Action Buttons Area --- (Vertical column) - # Row 2, Column 3: Vertical Button Frame - action_button_frame = ttk.Frame(self.commit_tag_frame) - action_button_frame.grid(row=2, column=3, sticky="ns", # North-South align - padx=(5,0), pady=(10,5)) # Match listbox padding + # 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) - button_width = 18 # Consistent width for vertical buttons + button_width_tag = 18 # Consistent width for this column self.refresh_tags_button = ttk.Button( - action_button_frame, text="Refresh Tags", width=button_width, + tag_button_frame, text="Refresh Tags", width=button_width_tag, command=self.refresh_tags_callback, state=tk.DISABLED ) self.refresh_tags_button.pack(side=tk.TOP, fill=tk.X, pady=(0, 3)) self.create_tooltip(self.refresh_tags_button, "Reload tag list.") self.create_tag_button = ttk.Button( - action_button_frame, text="Create Tag...", width=button_width, + tag_button_frame, text="Create Tag...", width=button_width_tag, command=self.create_tag_callback, state=tk.DISABLED ) self.create_tag_button.pack(side=tk.TOP, fill=tk.X, pady=3) self.create_tooltip(self.create_tag_button, - "Commit changes (requires message) and create tag.") + "Commit changes (requires message) and create new tag.") self.checkout_tag_button = ttk.Button( - action_button_frame, text="Checkout Selected Tag", width=button_width, + tag_button_frame, text="Checkout Selected Tag", width=button_width_tag, command=self.checkout_tag_callback, state=tk.DISABLED ) self.checkout_tag_button.pack(side=tk.TOP, fill=tk.X, pady=3) self.create_tooltip(self.checkout_tag_button, "Switch to selected tag (Detached HEAD).") - # Moved Edit .gitignore button here + # Edit .gitignore button moved here self.edit_gitignore_button = ttk.Button( - action_button_frame, text="Edit .gitignore", width=button_width, + tag_button_frame, text="Edit .gitignore", width=button_width_tag, command=self.open_gitignore_editor_callback, state=tk.DISABLED ) self.edit_gitignore_button.pack(side=tk.TOP, fill=tk.X, pady=(3, 0)) @@ -589,34 +820,30 @@ class MainFrame(ttk.Frame): self.function_frame = ttk.LabelFrame( self, text="Core Actions", padding=(10, 10) ) - self.function_frame.pack(pady=5, fill="x", anchor=tk.N) + self.function_frame.pack(pady=(10, 5), fill="x", anchor=tk.N) - # Sub-frame for button layout button_subframe = ttk.Frame(self.function_frame) - button_subframe.pack(fill=tk.X) + # Center the buttons within the frame + button_subframe.pack() - # Prepare SVN button self.prepare_svn_button = ttk.Button( button_subframe, text="Prepare SVN Repo", command=self.prepare_svn_for_git_callback ) self.prepare_svn_button.pack(side=tk.LEFT, padx=(0,5), pady=5) - # Create Bundle button self.create_bundle_button = ttk.Button( button_subframe, text="Create Bundle", command=self.create_git_bundle_callback ) self.create_bundle_button.pack(side=tk.LEFT, padx=5, pady=5) - # Fetch Bundle button self.fetch_bundle_button = ttk.Button( button_subframe, text="Fetch from Bundle", command=self.fetch_from_git_bundle_callback ) self.fetch_bundle_button.pack(side=tk.LEFT, padx=5, pady=5) - # Manual Backup Button self.manual_backup_button = ttk.Button( button_subframe, text="Backup Now (ZIP)", command=self.manual_backup_callback @@ -631,7 +858,7 @@ class MainFrame(ttk.Frame): padx=10, pady=(5, 10)) self.log_text = scrolledtext.ScrolledText( - log_frame, height=10, width=100, # Reduced height slightly + log_frame, height=8, width=100, # Adjusted height font=("Consolas", 9), wrap=tk.WORD, state=tk.DISABLED ) self.log_text.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True) @@ -639,13 +866,16 @@ class MainFrame(ttk.Frame): def _initialize_profile_selection(self): """Sets the initial value of the profile dropdown.""" - try: from config_manager import DEFAULT_PROFILE - except ImportError: DEFAULT_PROFILE = "default" + try: + from config_manager import DEFAULT_PROFILE + except ImportError: + DEFAULT_PROFILE = "default" if DEFAULT_PROFILE in self.initial_profile_sections: self.profile_var.set(DEFAULT_PROFILE) elif self.initial_profile_sections: self.profile_var.set(self.initial_profile_sections[0]) + # else: profile_var remains empty # --- GUI Update Methods --- @@ -660,10 +890,9 @@ class MainFrame(ttk.Frame): def browse_backup_dir(self): """Opens a directory selection dialog for backup directory.""" - initial_dir = self.backup_dir_var.get() or DEFAULT_BACKUP_DIR + initial = self.backup_dir_var.get() or DEFAULT_BACKUP_DIR dirname = filedialog.askdirectory( - initialdir=initial_dir, title="Select Backup Directory", - parent=self.master + initialdir=initial, title="Select Backup Dir", parent=self.master ) if dirname: self.backup_dir_var.set(dirname) @@ -671,15 +900,12 @@ class MainFrame(ttk.Frame): def update_svn_indicator(self, is_prepared): """Updates only the indicator color and Prepare button state.""" - if is_prepared: - color = self.GREEN; state = tk.DISABLED; tip = "Prepared" - else: - color = self.RED; state = tk.NORMAL; tip = "Not prepared" - # Update indicator + 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) - # Update Prepare button if hasattr(self, 'prepare_svn_button'): self.prepare_svn_button.config(state=state) @@ -709,7 +935,7 @@ class MainFrame(ttk.Frame): try: if self.tag_listbox.cget("fg") == "grey": self.tag_listbox.config(fg='SystemWindowText') - except tk.TclError: pass # Ignore errors + except tk.TclError: pass # Insert items for name, subject in tags_with_subjects: display = f"{name}\t({subject})" @@ -718,7 +944,7 @@ class MainFrame(ttk.Frame): # Show placeholder self.tag_listbox.insert(tk.END, "(No tags found)") try: self.tag_listbox.config(fg="grey") - except tk.TclError: pass # Ignore errors + 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) @@ -730,15 +956,68 @@ class MainFrame(ttk.Frame): if indices: item = self.tag_listbox.get(indices[0]) if item != "(No tags found)": - name = item.split('\t', 1)[0] # Get text before tab + name = item.split('\t', 1)[0] # Text before first tab return name.strip() return None + def update_branch_list(self, branches): + """Clears and repopulates the branch listbox.""" + if not hasattr(self, 'branch_listbox'): + logging.error("Branch listbox missing for update.") + return + try: + current_branch = self.current_branch_var.get() # Get displayed name + self.branch_listbox.delete(0, tk.END) + if branches: + # Reset color if needed + try: + if self.branch_listbox.cget("fg") == "grey": + self.branch_listbox.config(fg='SystemWindowText') + except tk.TclError: pass + # Insert branches, highlight current + for branch in branches: + is_current = (branch == current_branch) + display_name = f"* {branch}" if is_current else f" {branch}" + self.branch_listbox.insert(tk.END, display_name) + # Apply styling for current branch + if is_current: + self.branch_listbox.itemconfig( + tk.END, {'fg': 'blue', 'selectbackground': 'lightblue'} + ) + else: + # Show placeholder + 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) + + + def get_selected_branch(self): + """Returns the name only of the selected branch.""" + if hasattr(self, 'branch_listbox'): + indices = self.branch_listbox.curselection() + if indices: + item = self.branch_listbox.get(indices[0]) + # Remove potential '*' prefix and strip whitespace + return item.lstrip("* ").strip() + return None + + + 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)") + + # --- Dialog Wrappers --- def ask_new_profile_name(self): - return simpledialog.askstring("Add Profile", "Enter new profile name:", - parent=self.master) + return simpledialog.askstring("Add Profile", "Enter new profile name:", parent=self.master) def show_error(self, title, message): messagebox.showerror(title, message, parent=self.master) def show_info(self, title, message): @@ -756,6 +1035,7 @@ class MainFrame(ttk.Frame): widget.bind("", lambda e, tt=tooltip: tt.hidetip(), add='+') def update_tooltip(self, widget, text): + # Simple recreation is often easiest widget.unbind("") widget.unbind("") widget.unbind("")