# GitTool.py import os import shutil import datetime import tkinter as tk from tkinter import messagebox import logging import zipfile # Import application modules from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR from git_commands import GitCommands, GitCommandError from logger_config import setup_logger # Import GUI classes from gui import ( MainFrame, GitignoreEditorWindow, CreateTagDialog, CreateBranchDialog, ) # Include necessary dialogs class GitSvnSyncApp: """ Main application class for the Git SVN Sync Tool. Coordinates the GUI (now tabbed), configuration, and Git commands. """ def __init__(self, master): """ Initializes the GitSvnSyncApp. Args: master (tk.Tk): The main Tkinter root window. """ self.master = master master.title("Git SVN Sync Tool") master.protocol("WM_DELETE_WINDOW", self.on_closing) # Basic logging setup first logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s" ) self.logger = logging.getLogger("GitSvnSyncApp") # Initialize Configuration Manager try: self.config_manager = ConfigManager(self.logger) except Exception as e: self.logger.critical(f"Failed init ConfigManager: {e}", exc_info=True) self.show_fatal_error(f"Config Error:\n{e}") master.destroy() return # Initialize MainFrame (GUI) with all necessary callbacks try: self.main_frame = MainFrame( master, # Profile Load load_profile_settings_cb=self.load_profile_settings, # Browse browse_folder_cb=self.browse_folder, # Status Update (triggers UI state changes) update_svn_status_cb=self.update_svn_status_indicator, # Actions from Repo Tab prepare_svn_for_git_cb=self.prepare_svn_for_git, create_git_bundle_cb=self.create_git_bundle, fetch_from_git_bundle_cb=self.fetch_from_git_bundle, open_gitignore_editor_cb=self.open_gitignore_editor, # Action from Backup Tab manual_backup_cb=self.manual_backup, # Config Manager related config_manager_instance=self.config_manager, profile_sections_list=self.config_manager.get_profile_sections(), add_profile_cb=self.add_profile, remove_profile_cb=self.remove_profile, save_profile_cb=self.save_profile_settings, # Action from Commit Tab commit_changes_cb=self.commit_changes, # Actions from Tags Tab refresh_tags_cb=self.refresh_tag_list, create_tag_cb=self.create_tag, checkout_tag_cb=self.checkout_tag, # Actions from Branch Tab refresh_branches_cb=self.refresh_branch_list, checkout_branch_cb=self.checkout_branch, create_branch_cb=self.create_branch, # Action from History Tab refresh_history_cb=self.refresh_commit_history, ) except Exception as e: self.logger.critical(f"Failed init MainFrame: {e}", exc_info=True) self.show_fatal_error(f"GUI Error:\n{e}") master.destroy() return # Finalize Logger Setup using the GUI text widget self.logger = setup_logger(self.main_frame.log_text) self.config_manager.logger = self.logger # Ensure manager uses final logger # Initialize Git Commands Handler self.git_commands = GitCommands(self.logger) # --- Initial Application State --- self.logger.info("Application initializing...") initial_profile = self.main_frame.profile_var.get() if initial_profile: self.logger.debug(f"Initial profile: '{initial_profile}'. Loading...") # Settings load triggers refresh of tags, branches, history via trace else: self.logger.warning("No profile selected on startup.") self._clear_and_disable_fields() # Disable everything initially self.logger.info("Application started successfully.") def on_closing(self): """Handles the window close event.""" self.logger.info("Application closing.") # Add cleanup or checks for unsaved work here if needed self.master.destroy() # --- Profile Management --- def load_profile_settings(self, profile_name): """Loads settings for the selected profile into the GUI.""" self.logger.info(f"Loading settings for profile: '{profile_name}'") if not profile_name: self.logger.warning("No profile selected to load.") self._clear_and_disable_fields() # Reset UI if no profile return if profile_name not in self.config_manager.get_profile_sections(): self.logger.error(f"Profile '{profile_name}' not found.") self.main_frame.show_error("Error", f"Profile '{profile_name}' not found.") self._clear_and_disable_fields() return # Load data from config manager cm = self.config_manager svn_path = cm.get_profile_option(profile_name, "svn_working_copy_path", "") usb_path = cm.get_profile_option(profile_name, "usb_drive_path", "") bundle_name = cm.get_profile_option(profile_name, "bundle_name", "") bundle_upd = cm.get_profile_option(profile_name, "bundle_name_updated", "") autocommit = cm.get_profile_option(profile_name, "autocommit", "False") commit_msg = cm.get_profile_option(profile_name, "commit_message", "") autobackup = cm.get_profile_option(profile_name, "autobackup", "False") backup_dir = cm.get_profile_option( profile_name, "backup_dir", DEFAULT_BACKUP_DIR ) excludes = cm.get_profile_option( profile_name, "backup_exclude_extensions", ".log,.tmp" ) # Update GUI widgets if hasattr(self, "main_frame"): mf = self.main_frame # Repo Tab mf.svn_path_entry.delete(0, tk.END) mf.svn_path_entry.insert(0, svn_path) mf.usb_path_entry.delete(0, tk.END) mf.usb_path_entry.insert(0, usb_path) mf.bundle_name_entry.delete(0, tk.END) mf.bundle_name_entry.insert(0, bundle_name) mf.bundle_updated_name_entry.delete(0, tk.END) mf.bundle_updated_name_entry.insert(0, bundle_upd) # Backup Tab mf.autobackup_var.set(autobackup.lower() == "true") mf.backup_dir_var.set(backup_dir) mf.backup_exclude_extensions_var.set(excludes) mf.toggle_backup_dir() # Commit Tab mf.autocommit_var.set(autocommit.lower() == "true") mf.clear_commit_message() # Clear first if hasattr(mf, "commit_message_text"): if mf.commit_message_text.cget("state") == tk.DISABLED: mf.commit_message_text.config(state=tk.NORMAL) mf.commit_message_text.insert("1.0", commit_msg) # Insert loaded msg # State will be set by update_svn_status_indicator # Update Status & Enable/Disable Buttons self.update_svn_status_indicator(svn_path) self._enable_function_buttons() # Refresh dynamic lists if repo is ready repo_ready = ( svn_path and os.path.isdir(svn_path) and os.path.exists(os.path.join(svn_path, ".git")) ) if repo_ready: self.refresh_tag_list() self.refresh_branch_list() self.refresh_commit_history() else: # Clear lists if repo not ready mf.update_tag_list([]) mf.update_branch_list([], None) mf.update_history_display([]) mf.update_history_branch_filter([]) # Clear history filter too self.logger.info(f"Settings loaded for '{profile_name}'.") else: self.logger.error("Cannot load settings: Main frame missing.") def save_profile_settings(self): """Saves current GUI values to the selected profile.""" profile = self.main_frame.profile_var.get() if not profile: self.logger.warning("Cannot save: No profile selected.") return False self.logger.info(f"Saving settings for profile: '{profile}'") try: cm = self.config_manager mf = self.main_frame # Repo Tab cm.set_profile_option( profile, "svn_working_copy_path", mf.svn_path_entry.get() ) cm.set_profile_option(profile, "usb_drive_path", mf.usb_path_entry.get()) cm.set_profile_option(profile, "bundle_name", mf.bundle_name_entry.get()) cm.set_profile_option( profile, "bundle_name_updated", mf.bundle_updated_name_entry.get() ) # Commit Tab cm.set_profile_option(profile, "autocommit", str(mf.autocommit_var.get())) commit_msg = mf.get_commit_message() # Get from ScrolledText cm.set_profile_option(profile, "commit_message", commit_msg) # Backup Tab cm.set_profile_option(profile, "autobackup", str(mf.autobackup_var.get())) cm.set_profile_option(profile, "backup_dir", mf.backup_dir_var.get()) cm.set_profile_option( profile, "backup_exclude_extensions", mf.backup_exclude_extensions_var.get(), ) # Save to file cm.save_config() self.logger.info(f"Settings saved for '{profile}'.") return True except Exception as e: self.logger.error(f"Error saving '{profile}': {e}", exc_info=True) self.main_frame.show_error("Save Error", f"Failed save:\n{e}") return False def add_profile(self): """Handles adding a new profile.""" self.logger.debug("'Add Profile' clicked.") name = self.main_frame.ask_new_profile_name() if not name: self.logger.info("Add cancelled.") return name = name.strip() if not name: self.logger.warning("Empty name.") self.main_frame.show_error("Error", "Name empty.") return if name in self.config_manager.get_profile_sections(): self.logger.warning(f"Exists: '{name}'") self.main_frame.show_error("Error", f"'{name}' exists.") return self.logger.info(f"Adding profile: '{name}'") try: defaults = self.config_manager._get_expected_keys_with_defaults() defaults["bundle_name"] = f"{name}_repo.bundle" defaults["bundle_name_updated"] = f"{name}_update.bundle" defaults["svn_working_copy_path"] = "" defaults["usb_drive_path"] = "" for k, v in defaults.items(): self.config_manager.set_profile_option(name, k, v) self.config_manager.save_config() sections = self.config_manager.get_profile_sections() self.main_frame.update_profile_dropdown(sections) self.main_frame.profile_var.set(name) # Select new profile self.logger.info(f"Profile '{name}' added.") except Exception as e: self.logger.error(f"Error adding '{name}': {e}", exc_info=True) self.main_frame.show_error("Error", f"Failed add:\n{e}") def remove_profile(self): """Handles removing the selected profile.""" self.logger.debug("'Remove Profile' clicked.") profile = self.main_frame.profile_var.get() if not profile: self.logger.warning("No profile.") self.main_frame.show_error("Error", "No profile.") return if profile == DEFAULT_PROFILE: self.logger.warning("Cannot remove default.") self.main_frame.show_error("Error", f"Cannot remove '{DEFAULT_PROFILE}'.") return if self.main_frame.ask_yes_no("Remove Profile", f"Remove '{profile}'?"): self.logger.info(f"Removing: '{profile}'") try: if self.config_manager.remove_profile_section(profile): self.config_manager.save_config() self.logger.info("Removed.") sections = self.config_manager.get_profile_sections() self.main_frame.update_profile_dropdown( sections ) # Update list, selection changes automatically else: self.main_frame.show_error("Error", "Failed remove.") except Exception as e: self.logger.error(f"Error removing: {e}", exc_info=True) self.main_frame.show_error("Error", f"Error removing:\n{e}") else: self.logger.info("Removal cancelled.") # --- GUI Interaction --- def browse_folder(self, entry_widget): """Opens folder dialog to update an entry widget.""" self.logger.debug("Browse folder requested.") current = entry_widget.get() initial = current if os.path.isdir(current) else os.path.expanduser("~") directory = filedialog.askdirectory( initialdir=initial, title="Select Directory", parent=self.master ) if directory: self.logger.debug(f"Selected: {directory}") entry_widget.delete(0, tk.END) entry_widget.insert(0, directory) if entry_widget == self.main_frame.svn_path_entry: self.update_svn_status_indicator(directory) else: self.logger.debug("Browse cancelled.") def update_svn_status_indicator(self, svn_path): """Checks repo status and updates states of dependent GUI widgets.""" is_valid = bool(svn_path and os.path.isdir(svn_path)) is_ready = is_valid and os.path.exists(os.path.join(svn_path, ".git")) self.logger.debug( f"Updating status for '{svn_path}'. Valid:{is_valid}, Ready:{is_ready}" ) if hasattr(self, "main_frame"): mf = self.main_frame # Update indicator & Prepare button (Repo Tab) mf.update_svn_indicator(is_ready) # Determine states based on validity and readiness gitignore_state = tk.NORMAL if is_valid else tk.DISABLED repo_ready_state = tk.NORMAL if is_ready else tk.DISABLED # Update states: Repo Tab if hasattr(mf, "edit_gitignore_button"): mf.edit_gitignore_button.config(state=gitignore_state) if hasattr(mf, "create_bundle_button"): mf.create_bundle_button.config(state=repo_ready_state) if hasattr(mf, "fetch_bundle_button"): mf.fetch_bundle_button.config(state=repo_ready_state) # Update states: Backup Tab if hasattr(mf, "manual_backup_button"): mf.manual_backup_button.config(state=repo_ready_state) # Update states: Commit Tab if hasattr(mf, "autocommit_checkbox"): mf.autocommit_checkbox.config(state=repo_ready_state) if hasattr(mf, "commit_message_text"): mf.commit_message_text.config(state=repo_ready_state) if hasattr(mf, "commit_button"): mf.commit_button.config(state=repo_ready_state) # Update states: Tags Tab if hasattr(mf, "refresh_tags_button"): mf.refresh_tags_button.config(state=repo_ready_state) if hasattr(mf, "create_tag_button"): mf.create_tag_button.config(state=repo_ready_state) if hasattr(mf, "checkout_tag_button"): mf.checkout_tag_button.config(state=repo_ready_state) # Update states: Branches Tab if hasattr(mf, "refresh_branches_button"): mf.refresh_branches_button.config(state=repo_ready_state) if hasattr(mf, "create_branch_button"): mf.create_branch_button.config(state=repo_ready_state) if hasattr(mf, "checkout_branch_button"): mf.checkout_branch_button.config(state=repo_ready_state) # Update states: History Tab if hasattr(mf, "history_branch_filter_combo"): mf.history_branch_filter_combo.config(state=repo_ready_state) if hasattr(mf, "refresh_history_button"): mf.refresh_history_button.config(state=repo_ready_state) def open_gitignore_editor(self): """Opens the editor window for .gitignore.""" self.logger.info("--- Action: Edit .gitignore ---") svn_path = self._get_and_validate_svn_path("Edit .gitignore") if not svn_path: return gitignore_path = os.path.join(svn_path, ".gitignore") self.logger.debug(f"Target: {gitignore_path}") try: editor = GitignoreEditorWindow(self.master, gitignore_path, self.logger) self.logger.debug("Editor opened.") except Exception as e: self.logger.exception(f"Editor error: {e}") self.main_frame.show_error("Error", f"Editor error:\n{e}") def _get_and_validate_svn_path(self, operation_name="Operation"): """ Retrieves the SVN path from the GUI entry, validates its existence as a directory, shows errors in GUI, and logs issues. Args: operation_name (str): Name of the operation requesting the path (for logging/error messages). Returns: str: The validated absolute path if valid, None otherwise. """ # Check if main_frame and the specific widget exist first if not hasattr(self, 'main_frame') or \ not self.main_frame.winfo_exists() or \ not hasattr(self.main_frame, 'svn_path_entry'): self.logger.error(f"{operation_name}: GUI component unavailable.") # Avoid showing messagebox if GUI isn't fully ready # self.main_frame.show_error("GUI Error", "Cannot access path field.") return None svn_path_str = self.main_frame.svn_path_entry.get() svn_path_str = svn_path_str.strip() if not svn_path_str: self.logger.error(f"{operation_name}: SVN Working Copy Path is empty.") self.main_frame.show_error( "Input Error", "Please specify the SVN Working Copy Path." ) return None # Convert to absolute path and check if it's a directory abs_path = os.path.abspath(svn_path_str) if not os.path.isdir(abs_path): self.logger.error( f"{operation_name}: Specified SVN path is not a valid " f"directory: {abs_path}" ) self.main_frame.show_error( "Input Error", f"Invalid SVN path (not a directory):\n{abs_path}" ) return None # If all checks pass self.logger.debug(f"{operation_name}: Using validated SVN path: {abs_path}") return abs_path def _get_and_validate_usb_path(self, operation_name="Operation"): """ Retrieves the USB/Bundle Target path from the GUI entry, validates its existence as a directory, shows errors, and logs issues. Args: operation_name (str): Name of the operation requesting the path. Returns: str: The validated absolute path if valid, None otherwise. """ # Check if main_frame and the specific widget exist first if not hasattr(self, 'main_frame') or \ not self.main_frame.winfo_exists() or \ not hasattr(self.main_frame, 'usb_path_entry'): self.logger.error(f"{operation_name}: GUI component unavailable.") return None usb_path_str = self.main_frame.usb_path_entry.get() usb_path_str = usb_path_str.strip() if not usb_path_str: self.logger.error(f"{operation_name}: Bundle Target Dir path empty.") self.main_frame.show_error( "Input Error", "Please specify the Bundle Target Directory." ) return None # Convert to absolute path and check if it's a directory abs_path = os.path.abspath(usb_path_str) if not os.path.isdir(abs_path): self.logger.error( f"{operation_name}: Specified Bundle Target path is not a " f"valid directory: {abs_path}" ) self.main_frame.show_error( "Input Error", f"Invalid Bundle Target path (not a directory):\n{abs_path}" ) return None # If all checks pass self.logger.debug(f"{operation_name}: Using validated Bundle Target path: {abs_path}") return abs_path # --- Core Repo/Bundle/Backup Actions (now triggered from specific tabs) --- def prepare_svn_for_git(self): """Handles the 'Prepare SVN Repo' action (Repo Tab).""" self.logger.info("--- Action: Prepare Repo ---") svn_path = self._get_and_validate_svn_path("Prepare") if not svn_path: return if not self.save_profile_settings(): self.logger.warning("Prepare: Save failed.") if os.path.exists(os.path.join(svn_path, ".git")): self.logger.info("Already prepared.") self.main_frame.show_info("Info", "Already prepared.") self.update_svn_status_indicator(svn_path) return self.logger.info(f"Preparing: {svn_path}") try: self.git_commands.prepare_svn_for_git(svn_path) self.logger.info("Prepared.") self.main_frame.show_info("Success", "Repo prepared.") self.update_svn_status_indicator(svn_path) except (GitCommandError, ValueError) as e: self.logger.error(f"Prepare error: {e}") self.main_frame.show_error("Error", f"Failed:\n{e}") self.update_svn_status_indicator(svn_path) except Exception as e: self.logger.exception(f"Unexpected prepare error: {e}") self.main_frame.show_error("Error", f"Unexpected:\n{e}") self.update_svn_status_indicator(svn_path) def create_git_bundle(self): """Handles the 'Create Bundle' action (Repo Tab).""" self.logger.info("--- Action: Create Bundle ---") profile = self.main_frame.profile_var.get() if not profile: self.logger.error("Bundle: No profile.") self.main_frame.show_error("Error", "No profile.") return svn_path = self._get_and_validate_svn_path("Bundle") if not svn_path: return usb_path = self._get_and_validate_usb_path("Bundle") if not usb_path: return name = self.main_frame.bundle_name_entry.get().strip() if not name: self.logger.error("Bundle: Name empty.") self.main_frame.show_error("Error", "Bundle name empty.") return if not name.lower().endswith(".bundle"): self.logger.warning(f"Adding .bundle") name += ".bundle" mf = self.main_frame mf.bundle_name_entry.delete(0, tk.END) mf.bundle_name_entry.insert(0, name) full_path = os.path.join(usb_path, name) self.logger.debug(f"Target: {full_path}") if not self.save_profile_settings(): self.logger.warning("Bundle: Save failed.") # Backup Step if self.main_frame.autobackup_var.get(): self.logger.info("Autobackup...") if not self.create_backup(svn_path, profile): self.logger.error("Aborted: backup fail.") return # Autocommit Step (using checkbox and message from Commit Tab) if self.main_frame.autocommit_var.get(): self.logger.info("Autocommit before bundle...") try: if self.git_commands.git_status_has_changes(svn_path): self.logger.info("Changes detected for autocommit...") msg = ( self.main_frame.get_commit_message() or f"Autocommit '{profile}' before bundle" ) self.logger.debug(f"Autocommit msg: '{msg}'") if self.git_commands.git_commit(svn_path, msg): self.logger.info("Autocommit ok.") else: self.logger.info("No changes for autocommit.") except (GitCommandError, ValueError) as e: self.logger.error(f"Autocommit error: {e}") self.main_frame.show_error("Error", f"Autocommit Failed:\n{e}") return except Exception as e: self.logger.exception(f"Unexpected autocommit: {e}") self.main_frame.show_error("Error", f"Unexpected:\n{e}") return # Create Bundle Step self.logger.info(f"Creating bundle: {full_path}") try: self.git_commands.create_git_bundle(svn_path, full_path) if os.path.exists(full_path) and os.path.getsize(full_path) > 0: self.logger.info("Bundle created.") self.main_frame.show_info("Success", f"Bundle created:\n{full_path}") else: self.logger.warning("Bundle empty/not created.") self.main_frame.show_warning("Info", "Bundle empty/not created.") if os.path.exists(full_path): try: os.remove(full_path) self.logger.info("Removed empty.") except OSError as e: self.logger.warning(f"Cannot remove empty: {e}") except (GitCommandError, ValueError) as e: self.logger.error(f"Bundle error: {e}") self.main_frame.show_error("Error", f"Failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected bundle error: {e}") self.main_frame.show_error("Error", f"Unexpected:\n{e}") def fetch_from_git_bundle(self): """Handles the 'Fetch from Bundle' action (Repo Tab).""" self.logger.info("--- Action: Fetch Bundle ---") profile = self.main_frame.profile_var.get() if not profile: self.logger.error("Fetch: No profile.") self.main_frame.show_error("Error", "No profile.") return svn_path = self._get_and_validate_svn_path("Fetch") if not svn_path: return usb_path = self._get_and_validate_usb_path("Fetch") if not usb_path: return name = self.main_frame.bundle_updated_name_entry.get().strip() if not name: self.logger.error("Fetch: Name empty.") self.main_frame.show_error("Error", "Fetch name empty.") return full_path = os.path.join(usb_path, name) self.logger.debug(f"Source: {full_path}") if not os.path.isfile(full_path): self.logger.error(f"Fetch: Not found: {full_path}") self.main_frame.show_error("Error", f"Not found:\n{full_path}") return if not self.save_profile_settings(): self.logger.warning("Fetch: Could not save.") if self.main_frame.autobackup_var.get(): self.logger.info("Autobackup...") if not self.create_backup(svn_path, profile): self.logger.error("Aborted: backup fail.") return self.logger.info(f"Fetching into '{svn_path}' from: {full_path}") try: self.git_commands.fetch_from_git_bundle(svn_path, full_path) self.logger.info("Fetch/merge completed.") self.main_frame.show_info("Fetch Complete", "Fetch complete.\nCheck logs.") except GitCommandError as e: self.logger.error(f"Fetch/merge error: {e}") if "merge conflict" in str(e).lower(): self.main_frame.show_error( "Merge Conflict", f"Conflict.\nResolve in:\n{svn_path}\nThen commit.", ) else: self.main_frame.show_error("Error", f"Failed:\n{e}") except ValueError as e: self.logger.error(f"Validation error fetch: {e}") self.main_frame.show_error("Error", f"Invalid:\n{e}") except Exception as e: self.logger.exception(f"Unexpected fetch error: {e}") self.main_frame.show_error("Error", f"Unexpected:\n{e}") # --- Backup Logic (from Backup Tab) --- def _parse_exclusions(self, profile_name): """Parses exclusion string from config.""" exclude_str = self.config_manager.get_profile_option( profile_name, "backup_exclude_extensions", "" ) extensions = set() dirs = {".git", ".svn"} if exclude_str: for ext in exclude_str.split(","): clean = ext.strip().lower() if clean: extensions.add("." + clean if not clean.startswith(".") else clean) self.logger.debug( f"Exclusions '{profile_name}' - Ext:{extensions}, Dirs:{dirs}" ) return extensions, dirs def create_backup(self, source_path, profile): """Creates a timestamped ZIP backup.""" self.logger.info(f"Creating ZIP backup for '{profile}' from '{source_path}'") base_dir = self.main_frame.backup_dir_var.get().strip() if not base_dir: self.logger.error("Backup Fail: Dir empty.") self.main_frame.show_error("Error", "Backup dir empty.") return False if not os.path.isdir(base_dir): self.logger.info(f"Creating backup dir: {base_dir}") try: os.makedirs(base_dir, exist_ok=True) except OSError as e: self.logger.error(f"Cannot create dir: {e}") self.main_frame.show_error("Error", f"Cannot create dir:\n{e}") return False try: excluded_ext, excluded_dir = self._parse_exclusions(profile) except Exception as e: self.logger.error(f"Parse error: {e}") self.main_frame.show_error("Error", "Cannot parse exclusions.") return False now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") safe_prof = ( "".join(c for c in profile if c.isalnum() or c in "_-").rstrip() or "profile" ) filename = f"{now}_backup_{safe_prof}.zip" full_path = os.path.join(base_dir, filename) self.logger.info(f"Target ZIP: {full_path}") added, excl_f, excl_d = 0, 0, 0 zip_f = None try: zip_f = zipfile.ZipFile(full_path, "w", zipfile.ZIP_DEFLATED, True) for root, dirs, files in os.walk(source_path, topdown=True): orig_dirs = list(dirs) dirs[:] = [d for d in dirs if d.lower() not in excluded_dir] excl_now = set(orig_dirs) - set(dirs) if excl_now: excl_d += len(excl_now) for d in excl_now: self.logger.debug(f"Excl Dir: {os.path.join(root, d)}") for file in files: _, ext = os.path.splitext(file) ext_low = ext.lower() if file.lower() in excluded_dir or ext_low in excluded_ext: self.logger.debug(f"Excl File: {os.path.join(root, file)}") excl_f += 1 continue file_path = os.path.join(root, file) arc_name = os.path.relpath(file_path, source_path) try: zip_f.write(file_path, arc_name) added += 1 if added % 500 == 0: self.logger.debug(f"Added {added}...") except Exception as we: self.logger.error( f"Write error '{file_path}': {we}", exc_info=True ) self.logger.info( f"ZIP finished: {full_path} (Add:{added}, ExF:{excl_f}, ExD:{excl_d})" ) return True except OSError as e: self.logger.error(f"OS error ZIP: {e}") self.main_frame.show_error("Error", f"OS Error:\n{e}") return False except zipfile.BadZipFile as e: self.logger.error(f"ZIP format error: {e}") self.main_frame.show_error("Error", f"ZIP format:\n{e}") return False except Exception as e: self.logger.exception(f"Unexpected ZIP error: {e}") self.main_frame.show_error("Error", f"Unexpected ZIP:\n{e}") return False finally: if zip_f: zip_f.close() self.logger.debug("ZIP closed.") if os.path.exists(full_path) and added == 0: self.logger.warning(f"Empty ZIP: {full_path}") try: os.remove(full_path) self.logger.info("Removed empty ZIP.") except OSError as e: self.logger.error(f"Failed remove empty ZIP: {e}") def manual_backup(self): """Handles the 'Backup Now' button click (Backup Tab).""" self.logger.info("--- Action: Manual Backup ---") profile = self.main_frame.profile_var.get() if not profile: self.logger.warning("Backup: No profile.") self.main_frame.show_error("Error", "No profile.") return svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})") if not svn_path: return self.logger.info("Saving settings before backup...") if not self.save_profile_settings(): self.logger.error("Backup: Could not save settings.") if not self.main_frame.ask_yes_no( "Error", "Could not save.\nContinue anyway?" ): self.logger.warning("Backup aborted.") return self.logger.info(f"Starting manual backup for '{profile}'...") success = self.create_backup(svn_path, profile) # Calls ZIP backup if success: self.main_frame.show_info("Success", "Manual ZIP backup completed.") else: self.logger.error( f"Manual backup failed for '{profile}'." ) # Error shown by create_backup # --- Commit Action (from Commit Tab) --- def commit_changes(self): """Handles the 'Commit Changes' button click (Commit Tab).""" self.logger.info("--- Action: Commit Changes ---") svn_path = self._get_and_validate_svn_path("Commit Changes") if not svn_path: return # Get commit message from the ScrolledText widget commit_msg = self.main_frame.get_commit_message() if not commit_msg: self.logger.warning("Commit failed: Message is empty.") self.main_frame.show_error( "Commit Error", "Commit message cannot be empty." ) return # Save settings before commit (optional, ensures consistency) if not self.save_profile_settings(): self.logger.warning("Commit Changes: Could not save settings first.") # Perform the commit try: commit_made = self.git_commands.git_commit(svn_path, commit_msg) if commit_made: self.logger.info("Commit successful.") self.main_frame.show_info("Success", "Changes committed.") # Clear the commit message box after successful commit self.main_frame.clear_commit_message() # Refresh history view after commit self.refresh_commit_history() else: # git_commit logs "nothing to commit" self.main_frame.show_info("No Changes", "No changes to commit.") except (GitCommandError, ValueError) as e: self.logger.error(f"Commit failed: {e}") self.main_frame.show_error("Commit Error", f"Failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected commit error: {e}") self.main_frame.show_error("Error", f"Unexpected:\n{e}") # --- Tag Management (from Tags Tab) --- def refresh_tag_list(self): """Fetches tags with subjects and updates GUI listbox.""" self.logger.info("--- Action: Refresh Tags ---") svn_path = self._get_and_validate_svn_path("Refresh Tags") if not svn_path or not os.path.exists(os.path.join(svn_path, ".git")): if hasattr(self, "main_frame"): self.main_frame.update_tag_list([]) return # Silently return if repo not ready try: tags_data = self.git_commands.list_tags(svn_path) # Returns tuples if hasattr(self, "main_frame"): self.main_frame.update_tag_list(tags_data) self.logger.info(f"Tag list updated ({len(tags_data)} tags).") except Exception as e: self.logger.error(f"Failed refresh tags: {e}", exc_info=True) self.main_frame.show_error("Error", f"Could not refresh tags:\n{e}") if hasattr(self, "main_frame"): self.main_frame.update_tag_list([]) def create_tag(self): """Handles 'Create Tag': commits (if needed), shows dialog, creates tag.""" self.logger.info("--- Action: Create Tag ---") svn_path = self._get_and_validate_svn_path("Create Tag") if not svn_path: return profile = self.main_frame.profile_var.get() # Needed for commit msg? if not profile: self.logger.error("Tag Error: No profile") self.main_frame.show_error("Error", "No profile.") return if not self.save_profile_settings(): self.logger.warning("Tag: Could not save.") # 1. Commit outstanding changes IF message is provided try: if self.git_commands.git_status_has_changes(svn_path): self.logger.info("Uncommitted changes detected.") commit_msg = self.main_frame.get_commit_message() if not commit_msg: self.logger.error("Tag blocked: Changes exist, no commit msg.") self.main_frame.show_error( "Commit Required", "Changes exist.\nEnter commit message first." ) return confirm_msg = f"Commit changes with message:\n'{commit_msg}'?" if not self.main_frame.ask_yes_no("Confirm Commit", confirm_msg): self.logger.info("Commit cancelled.") self.main_frame.show_warning("Cancelled", "Tag creation cancelled.") return if self.git_commands.git_commit(svn_path, commit_msg): self.logger.info("Pre-tag commit successful.") self.main_frame.clear_commit_message() # Clear after use else: self.logger.info("No changes detected before tagging.") except (GitCommandError, ValueError) as e: self.logger.error(f"Pre-tag commit error: {e}") self.main_frame.show_error("Commit Error", f"Failed:\n{e}") return except Exception as e: self.logger.exception(f"Unexpected pre-tag commit error: {e}") self.main_frame.show_error("Error", f"Unexpected:\n{e}") return # 2. Open Dialog for Tag Name and Tag Message self.logger.debug("Opening create tag dialog...") dialog = CreateTagDialog(self.master) tag_info = dialog.result # (name, message) or None # 3. Create Tag if user provided info if tag_info: tag_name, tag_message = tag_info self.logger.info(f"Creating tag '{tag_name}'...") try: self.git_commands.create_tag(svn_path, tag_name, tag_message) self.logger.info(f"Tag '{tag_name}' created.") self.main_frame.show_info("Success", f"Tag '{tag_name}' created.") self.refresh_tag_list() # Refresh UI list self.refresh_commit_history() # Show new tag in history except (GitCommandError, ValueError) as e: self.logger.error(f"Failed create tag '{tag_name}': {e}") self.main_frame.show_error("Tag Error", f"Failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected tag error: {e}") self.main_frame.show_error("Error", f"Unexpected:\n{e}") else: self.logger.info("Tag creation cancelled in dialog.") def checkout_tag(self): """Handles the 'Checkout Selected Tag' action.""" self.logger.info("--- Action: Checkout Tag ---") svn_path = self._get_and_validate_svn_path("Checkout Tag") if not svn_path: return selected_tag = self.main_frame.get_selected_tag() # Gets name if not selected_tag: self.logger.warning("Checkout: No tag selected.") self.main_frame.show_error("Error", "Select tag.") return self.logger.info(f"Attempting checkout: {selected_tag}") try: # Check changes if self.git_commands.git_status_has_changes(svn_path): self.logger.error("Checkout blocked: Changes.") self.main_frame.show_error("Blocked", "Changes exist.") return self.logger.debug("No changes found.") except (GitCommandError, ValueError) as e: self.logger.error(f"Status check error: {e}") self.main_frame.show_error("Error", f"Status check:\n{e}") return except Exception as e: self.logger.exception(f"Unexpected status check: {e}") self.main_frame.show_error("Error", f"Unexpected status:\n{e}") return # Confirm confirm_msg = f"Checkout tag '{selected_tag}'?\n\nWARNINGS:\n- Files overwritten.\n- NO backup.\n- Detached HEAD state." if not self.main_frame.ask_yes_no("Confirm Checkout", confirm_msg): self.logger.info("Checkout cancelled.") return self.logger.info(f"Confirmed checkout: {selected_tag}") if not self.save_profile_settings(): self.logger.warning("Checkout: Save failed.") # Execute try: if self.git_commands.checkout_tag(svn_path, selected_tag): self.logger.info(f"Tag '{selected_tag}' checked out.") self.main_frame.show_info( "Success", f"Checked out '{selected_tag}'.\n\nNOTE: Detached HEAD state.", ) self.refresh_branch_list() # Update current branch display self.refresh_commit_history() # Update history view except (GitCommandError, ValueError) as e: self.logger.error(f"Failed checkout '{selected_tag}': {e}") self.main_frame.show_error("Error", f"Checkout failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected checkout error: {e}") self.main_frame.show_error("Error", f"Unexpected:\n{e}") # --- Branch Management Methods (from Branch Tab) --- def refresh_branch_list(self): """Fetches local branches and updates the Branch tab listbox.""" self.logger.info("--- Action: Refresh Branches ---") svn_path = self._get_and_validate_svn_path("Refresh Branches") current_branch_for_history = None # Keep track for history filter update if not svn_path or not os.path.exists(os.path.join(svn_path, ".git")): if hasattr(self, "main_frame"): self.main_frame.update_branch_list([], None) self.main_frame.update_history_branch_filter( [] ) # Clear history filter too return # Silently return if repo not ready try: branches, current = self.git_commands.list_branches(svn_path) current_branch_for_history = current # Store current branch if hasattr(self, "main_frame"): self.main_frame.update_branch_list(branches, current) # Update the history filter dropdown as well self.main_frame.update_history_branch_filter(branches, current) self.logger.info( f"Branch list updated ({len(branches)} branches). Current: {current}" ) except Exception as e: self.logger.error(f"Failed refresh branches: {e}", exc_info=True) self.main_frame.show_error("Error", f"Could not refresh branches:\n{e}") if hasattr(self, "main_frame"): self.main_frame.update_branch_list([], None) self.main_frame.update_history_branch_filter([]) def checkout_branch(self): """Handles the 'Checkout Selected Branch' action.""" self.logger.info("--- Action: Checkout Branch ---") svn_path = self._get_and_validate_svn_path("Checkout Branch") if not svn_path: return selected_branch = self.main_frame.get_selected_branch() if not selected_branch: self.logger.warning("Checkout Branch: No branch selected.") self.main_frame.show_error("Selection Error", "Select a branch.") return self.logger.info(f"Attempting checkout for branch: {selected_branch}") # Check for uncommitted changes try: if self.git_commands.git_status_has_changes(svn_path): self.logger.error("Checkout blocked: Uncommitted changes.") self.main_frame.show_error( "Blocked", "Uncommitted changes exist.\nCommit or stash first." ) return self.logger.debug("No uncommitted changes found.") except (GitCommandError, ValueError) as e: self.logger.error(f"Status check error: {e}") self.main_frame.show_error("Error", f"Cannot check status:\n{e}") return except Exception as e: self.logger.exception(f"Unexpected status error: {e}") self.main_frame.show_error("Error", f"Unexpected status:\n{e}") return # Confirm checkout if not self.main_frame.ask_yes_no( "Confirm Checkout", f"Switch to branch '{selected_branch}'?" ): self.logger.info("Branch checkout cancelled.") return self.logger.info(f"Confirmed checkout: {selected_branch}") if not self.save_profile_settings(): self.logger.warning("Checkout: Save failed.") # Execute checkout try: if self.git_commands.checkout_branch(svn_path, selected_branch): self.logger.info(f"Branch '{selected_branch}' checked out.") self.main_frame.show_info( "Success", f"Switched to branch '{selected_branch}'." ) self.refresh_branch_list() # Update list highlighting self.refresh_commit_history() # Update history view except (GitCommandError, ValueError) as e: self.logger.error(f"Failed checkout '{selected_branch}': {e}") self.main_frame.show_error("Error", f"Checkout failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected checkout error: {e}") self.main_frame.show_error("Error", f"Unexpected:\n{e}") def create_branch(self): """Handles the 'Create Branch' action.""" self.logger.info("--- Action: Create Branch ---") svn_path = self._get_and_validate_svn_path("Create Branch") if not svn_path: return # Open dialog to get new branch name dialog = CreateBranchDialog(self.master) new_branch_name = dialog.result if new_branch_name: self.logger.info(f"User wants new branch: '{new_branch_name}'") if not self.save_profile_settings(): self.logger.warning("Create Branch: Save failed.") # Execute create command try: if self.git_commands.create_branch(svn_path, new_branch_name): self.logger.info(f"Branch '{new_branch_name}' created.") self.main_frame.show_info( "Success", f"Branch '{new_branch_name}' created." ) self.refresh_branch_list() # Update list # Ask user if they want to switch to the new branch if self.main_frame.ask_yes_no( "Checkout New Branch?", f"Switch to new branch '{new_branch_name}'?", ): # Perform checkout (no need for change check or confirmation here) try: if self.git_commands.checkout_branch( svn_path, new_branch_name ): self.logger.info( f"Checked out new branch '{new_branch_name}'." ) self.refresh_branch_list() # Update highlight again self.refresh_commit_history() # else: checkout failed, error logged by command except (GitCommandError, ValueError) as chk_e: self.logger.error(f"Failed checkout new branch: {chk_e}") self.main_frame.show_error( "Checkout Error", f"Failed switch:\n{chk_e}" ) except Exception as chk_e_unex: self.logger.exception( f"Unexpected checkout error: {chk_e_unex}" ) self.main_frame.show_error( "Error", f"Unexpected switch:\n{chk_e_unex}" ) else: # Refresh history even if not checking out self.refresh_commit_history() except (GitCommandError, ValueError) as e: self.logger.error(f"Failed create branch '{new_branch_name}': {e}") self.main_frame.show_error("Error", f"Create failed:\n{e}") except Exception as e: self.logger.exception(f"Unexpected create branch: {e}") self.main_frame.show_error("Error", f"Unexpected:\n{e}") else: self.logger.info("Branch creation cancelled.") # --- History Method (from History Tab) --- def refresh_commit_history(self): """Fetches commit log based on filters and updates display.""" self.logger.info("--- Action: Refresh History ---") svn_path = self._get_and_validate_svn_path("Refresh History") if not svn_path or not os.path.exists(os.path.join(svn_path, ".git")): if hasattr(self, "main_frame"): self.main_frame.update_history_display([]) return # Silently return if repo not ready # Get selected branch filter from GUI branch_filter = None if hasattr(self.main_frame, "history_branch_filter_var"): selected_filter = self.main_frame.history_branch_filter_var.get() if selected_filter and selected_filter != "-- All History --": branch_filter = selected_filter # Use selected branch name self.logger.debug(f"Refreshing history with filter: {branch_filter}") try: # Get log entries with optional filter log_data = self.git_commands.get_commit_log( svn_path, max_count=200, branch=branch_filter # Get more entries? ) if hasattr(self, "main_frame"): self.main_frame.update_history_display(log_data) self.logger.info(f"History updated ({len(log_data)} entries).") except Exception as e: self.logger.error(f"Failed refresh history: {e}", exc_info=True) self.main_frame.show_error("Error", f"Could not refresh history:\n{e}") if hasattr(self, "main_frame"): self.main_frame.update_history_display([]) # --- GUI State Utilities --- def _clear_and_disable_fields(self): """Clears fields and disables most buttons when no profile/repo.""" if hasattr(self, "main_frame"): mf = self.main_frame # Clear Repo Tab mf.svn_path_entry.delete(0, tk.END) mf.usb_path_entry.delete(0, tk.END) mf.bundle_name_entry.delete(0, tk.END) mf.bundle_updated_name_entry.delete(0, tk.END) # Clear Commit Tab mf.clear_commit_message() mf.autocommit_var.set(False) # Clear Tags Tab mf.update_tag_list([]) # Clear Branch Tab mf.update_branch_list([], None) # Clear History Tab mf.update_history_display([]) mf.update_history_branch_filter([]) # Update status (disables state-dependent widgets) self.update_svn_status_indicator("") # Disable general buttons self._disable_general_buttons() self.logger.debug("GUI fields cleared/reset. Buttons disabled.") def _disable_general_buttons(self): """Disables general buttons requiring a profile.""" if hasattr(self, "main_frame"): # Only Save Settings is truly general now button = getattr(self.main_frame, "save_settings_button", None) if button: button.config(state=tk.DISABLED) def _enable_function_buttons(self): """Enables general buttons. State buttons handled by status update.""" if hasattr(self, "main_frame"): general_state = tk.NORMAL button = getattr(self.main_frame, "save_settings_button", None) if button: button.config(state=general_state) # Trigger state update for all repo-dependent widgets self.update_svn_status_indicator(self.main_frame.svn_path_entry.get()) self.logger.debug("General buttons enabled. State buttons updated.") def show_fatal_error(self, message): """Shows a fatal error message.""" try: parent = ( self.master if hasattr(self, "master") and self.master and self.master.winfo_exists() else None ) messagebox.showerror("Fatal Error", message, parent=parent) except Exception as e: print(f"FATAL ERROR (+GUI error {e}): {message}") # --- Application Entry Point --- def main(): """Main function: Creates Tkinter root and runs the application.""" logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s:%(message)s" ) root = tk.Tk() # Adjust min height slightly for history root.minsize(750, 800) app = None try: app = GitSvnSyncApp(root) if hasattr(app, "main_frame") and app.main_frame: root.mainloop() else: print("App init failed.") if root and root.winfo_exists(): root.destroy() except Exception as e: logging.exception("Fatal error during startup/mainloop.") try: parent = root if root and root.winfo_exists() else None messagebox.showerror("Fatal Error", f"App failed:\n{e}", parent=parent) except Exception as msg_e: print(f"FATAL ERROR (GUI error: {msg_e}):\n{e}") finally: logging.info("Application exiting.") if __name__ == "__main__": main()