SXXXXXXX_GitUtility/GitUtility.py
2025-04-07 15:01:25 +02:00

1070 lines
48 KiB
Python

# GitUtility.py
import os
# import shutil # Not needed here anymore
import datetime
import tkinter as tk
from tkinter import messagebox
import logging
# import zipfile # Not needed here anymore
# Import application modules
from config_manager import ConfigManager, DEFAULT_PROFILE, DEFAULT_BACKUP_DIR
from git_commands import GitCommands, GitCommandError
from logger_config import setup_logger
# Import GUI classes
from gui import MainFrame, GitignoreEditorWindow, CreateTagDialog, CreateBranchDialog
# Import Handler classes
from backup_handler import BackupHandler
from profile_handler import ProfileHandler
from action_handler import ActionHandler
class GitSvnSyncApp:
"""
Main application class: Coordinates GUI, configuration, and actions.
Delegates logic to specific handler classes.
"""
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)
# --- Early Logger Setup ---
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s"
)
self.logger = logging.getLogger("GitSvnSyncApp")
# --- Initialize Core Components ---
try:
self.config_manager = ConfigManager(self.logger)
self.git_commands = GitCommands(self.logger)
# Initialize Handlers
self.profile_handler = ProfileHandler(self.logger, self.config_manager)
self.backup_handler = BackupHandler(self.logger)
self.action_handler = ActionHandler(
self.logger, self.git_commands, self.backup_handler
)
except Exception as e:
self.logger.critical(f"Failed component init: {e}", exc_info=True)
self.show_fatal_error(f"Initialization Error:\n{e}\nApp cannot start.")
master.destroy()
return # Stop initialization
# --- 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,
# Core Action Callbacks
prepare_svn_for_git_cb=self.ui_prepare_svn,
create_git_bundle_cb=self.ui_create_bundle,
fetch_from_git_bundle_cb=self.ui_fetch_bundle,
manual_backup_cb=self.ui_manual_backup,
# Profile/Settings Callbacks
add_profile_cb=self.ui_add_profile,
remove_profile_cb=self.ui_remove_profile,
save_profile_cb=self.ui_save_settings,
# Commit/Gitignore Callbacks
manual_commit_cb=self.ui_manual_commit,
open_gitignore_editor_cb=self.open_gitignore_editor,
# Tag Callbacks
refresh_tags_cb=self.refresh_tag_list,
create_tag_cb=self.ui_create_tag,
checkout_tag_cb=self.ui_checkout_tag,
# Branch Callbacks
refresh_branches_cb=self.refresh_branch_list,
create_branch_cb=self.ui_create_branch,
switch_branch_cb=self.ui_switch_branch,
delete_branch_cb=self.ui_delete_branch,
# Pass instances/data if needed by GUI
config_manager_instance=self.config_manager,
profile_sections_list=self.profile_handler.get_profile_list()
)
except Exception as e:
self.logger.critical(f"Failed init MainFrame: {e}", exc_info=True)
self.show_fatal_error(f"GUI Error:\n{e}\nApp cannot start.")
master.destroy()
return
# --- Enhanced Logger Setup ---
self.logger = setup_logger(self.main_frame.log_text)
# Ensure all components use the final logger
self.config_manager.logger = self.logger
self.git_commands.logger = self.logger
self.profile_handler.logger = self.logger
self.backup_handler.logger = self.logger
self.action_handler.logger = self.logger
# --- 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)
else:
self.logger.warning("No profile selected on startup.")
self._clear_and_disable_fields()
self.logger.info("Application started successfully.")
def on_closing(self):
"""Handles window close event."""
self.logger.info("Application closing.")
self.master.destroy()
# --- Helper methods to get/validate paths from GUI ---
def _get_and_validate_svn_path(self, operation_name="Operation"):
"""Retrieves and validates the SVN path from the GUI."""
if not hasattr(self, 'main_frame'):
self.logger.error(f"{operation_name}: GUI 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 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 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}")
return abs_path
def _get_and_validate_usb_path(self, operation_name="Operation"):
"""Retrieves and validates the USB/Bundle Target path from the GUI."""
if not hasattr(self, 'main_frame'):
self.logger.error(f"{operation_name}: GUI 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 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: {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
# --- Profile Handling Wrappers ---
def load_profile_settings(self, profile_name):
"""Loads profile settings into GUI when profile selection changes."""
self.logger.info(f"UI Request: Load profile '{profile_name}'")
if not profile_name:
self._clear_and_disable_fields()
return
# Delegate loading to ProfileHandler
profile_data = self.profile_handler.load_profile_data(profile_name)
if profile_data and hasattr(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)
mf.usb_path_entry.insert(0, profile_data.get("usb_drive_path", ""))
mf.bundle_name_entry.delete(0, tk.END)
mf.bundle_name_entry.insert(0, profile_data.get("bundle_name", ""))
mf.bundle_updated_name_entry.delete(0, tk.END)
mf.bundle_updated_name_entry.insert(0, profile_data.get("bundle_name_updated", ""))
mf.autocommit_var.set(profile_data.get("autocommit", False)) # Bool
mf.commit_message_var.set(profile_data.get("commit_message", ""))
mf.autobackup_var.set(profile_data.get("autobackup", False)) # Bool
mf.backup_dir_var.set(profile_data.get("backup_dir", DEFAULT_BACKUP_DIR))
mf.backup_exclude_extensions_var.set(
profile_data.get("backup_exclude_extensions", ".log,.tmp")
)
mf.toggle_backup_dir() # Update backup dir entry state
# Update status indicators and enable general buttons
svn_path = profile_data.get("svn_working_copy_path", "")
self.update_svn_status_indicator(svn_path) # Updates state widgets
self._enable_function_buttons()
# Refresh tag and branch lists if repo ready
repo_ready = (svn_path and os.path.isdir(svn_path) and
os.path.exists(os.path.join(svn_path, ".git")))
if repo_ready:
self.refresh_tag_list()
self.refresh_branch_list() # Refresh branches
self.update_current_branch_display() # Update current branch label
else:
# Clear lists if not ready
mf.update_tag_list([])
mf.update_branch_list([])
mf.set_current_branch_display("<N/A>")
self.logger.info(f"Settings loaded successfully for '{profile_name}'.")
elif not profile_data:
# Profile loading failed
self.main_frame.show_error("Load Error", f"Could not load '{profile_name}'.")
self._clear_and_disable_fields()
else:
self.logger.error("Cannot load settings: Main frame missing.")
def ui_save_settings(self):
"""Callback for the 'Save Settings' button."""
self.logger.debug("UI Request: Save Settings button clicked.")
profile = self.main_frame.profile_var.get()
if not profile:
self.main_frame.show_error("Save Error", "No profile selected.")
return # Return False? No, just don't proceed.
# 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 saved for '{profile}'.")
else:
# Error message shown by handler or save method? Assume shown.
# self.main_frame.show_error("Save Error", f"Failed to save settings.")
pass # Error already shown likely
def _get_data_from_gui(self):
"""Helper to gather current settings from GUI widgets into a dict."""
if not hasattr(self, 'main_frame'):
self.logger.error("Cannot get GUI data: Main frame missing.")
return None # Return None or empty dict? None indicates failure better.
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(), # Boolean
"commit_message": mf.commit_message_var.get(),
"autobackup": mf.autobackup_var.get(), # Boolean
"backup_dir": mf.backup_dir_var.get(),
"backup_exclude_extensions": mf.backup_exclude_extensions_var.get()
}
return data
def ui_add_profile(self):
"""Callback for the 'Add Profile' button."""
self.logger.debug("UI Request: Add Profile.")
new_name = self.main_frame.ask_new_profile_name()
if not new_name:
self.logger.info("Add profile cancelled."); return
new_name = new_name.strip()
if not new_name:
self.main_frame.show_error("Error", "Profile name cannot be empty."); return
# Delegate adding logic
success = self.profile_handler.add_new_profile(new_name)
if success:
# Update GUI dropdown and select the new profile
sections = self.profile_handler.get_profile_list()
self.main_frame.update_profile_dropdown(sections)
self.main_frame.profile_var.set(new_name) # Triggers load
self.main_frame.show_info("Profile Added", f"Profile '{new_name}' created.")
else:
# Handler logged 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.")
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
msg = f"Remove profile '{profile}'?"
if self.main_frame.ask_yes_no("Remove Profile", msg):
# Delegate removal
success = self.profile_handler.remove_existing_profile(profile)
if success:
sections = self.profile_handler.get_profile_list()
self.main_frame.update_profile_dropdown(sections) # Triggers load
self.main_frame.show_info("Removed", f"Profile '{profile}' removed.")
else:
# Handler logged reason
self.main_frame.show_error("Error", f"Failed to remove '{profile}'.")
else:
self.logger.info("Profile removal cancelled.")
# --- GUI Interaction Wrappers ---
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)
# Update status if SVN path changed
if entry_widget == self.main_frame.svn_path_entry:
self.update_svn_status_indicator(directory)
else:
self.logger.debug("Browse cancelled.")
def update_svn_status_indicator(self, svn_path):
"""Checks repo status and updates all dependent GUI widget states."""
is_valid = bool(svn_path and os.path.isdir(svn_path))
is_ready = is_valid and os.path.exists(os.path.join(svn_path, ".git"))
self.logger.debug(f"Updating status indicators for '{svn_path}'. "
f"Valid:{is_valid}, Ready:{is_ready}")
if hasattr(self, 'main_frame'):
mf = self.main_frame
# Update indicator & Prepare button via GUI method
mf.update_svn_indicator(is_ready)
# Determine states for other widgets based on validity/readiness
gitignore_state = tk.NORMAL if is_valid else tk.DISABLED
commit_tag_branch_state = tk.NORMAL if is_ready else tk.DISABLED
# Apply states to relevant widgets
if hasattr(mf, 'edit_gitignore_button'):
mf.edit_gitignore_button.config(state=gitignore_state)
if hasattr(mf, 'commit_message_entry'):
mf.commit_message_entry.config(state=commit_tag_branch_state)
if hasattr(mf, 'autocommit_checkbox'):
mf.autocommit_checkbox.config(state=commit_tag_branch_state)
if hasattr(mf, 'commit_button'): # Manual commit button
mf.commit_button.config(state=commit_tag_branch_state)
# Branch widgets
if hasattr(mf, 'refresh_branches_button'):
mf.refresh_branches_button.config(state=commit_tag_branch_state)
if hasattr(mf, 'create_branch_button'):
mf.create_branch_button.config(state=commit_tag_branch_state)
if hasattr(mf, 'switch_branch_button'):
mf.switch_branch_button.config(state=commit_tag_branch_state)
if hasattr(mf, 'delete_branch_button'):
mf.delete_branch_button.config(state=commit_tag_branch_state)
# Tag widgets
if hasattr(mf, 'refresh_tags_button'):
mf.refresh_tags_button.config(state=commit_tag_branch_state)
if hasattr(mf, 'create_tag_button'):
mf.create_tag_button.config(state=commit_tag_branch_state)
if hasattr(mf, 'checkout_tag_button'):
mf.checkout_tag_button.config(state=commit_tag_branch_state)
# Update current branch display if repo not ready
if not is_ready:
mf.set_current_branch_display("<N/A>")
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 # 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 window
editor = GitignoreEditorWindow(self.master, gitignore_path, self.logger)
self.logger.debug("Gitignore editor finished.") # After window closes
except Exception as e:
self.logger.exception(f"Error opening .gitignore editor: {e}")
self.main_frame.show_error("Editor Error",
f"Could not open editor:\n{e}")
# --- Core Action Wrappers (GUI Callbacks) ---
def ui_prepare_svn(self):
"""Callback for 'Prepare SVN Repo' button."""
self.logger.info("--- Action Triggered: Prepare SVN Repo ---")
svn_path = self._get_and_validate_svn_path("Prepare SVN")
if not svn_path: return
# Save settings before action
if not self.ui_save_settings():
self.logger.warning("Prepare SVN: Failed to save settings first.")
# Ask user?
# Delegate execution to ActionHandler
try:
self.action_handler.execute_prepare_repo(svn_path)
self.main_frame.show_info("Success", "Repository prepared.")
# Update GUI state after successful preparation
self.update_svn_status_indicator(svn_path)
except ValueError as e: # Catch specific "already prepared" error
self.logger.info(f"Prepare Repo info: {e}")
self.main_frame.show_info("Info", str(e))
self.update_svn_status_indicator(svn_path) # Ensure UI reflects state
except (GitCommandError, IOError) as e:
self.logger.error(f"Error preparing repository: {e}")
self.main_frame.show_error("Error", f"Failed prepare:\n{e}")
self.update_svn_status_indicator(svn_path) # Update state
except Exception as e:
self.logger.exception(f"Unexpected error during preparation: {e}")
self.main_frame.show_error("Error", f"Unexpected error:\n{e}")
self.update_svn_status_indicator(svn_path)
def ui_create_bundle(self):
"""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 selected.")
return
# Validate inputs
svn_path = self._get_and_validate_svn_path("Create Bundle")
if not svn_path: return
usb_path = self._get_and_validate_usb_path("Create Bundle")
if not usb_path: return
bundle_name = self.main_frame.bundle_name_entry.get().strip()
if not bundle_name:
self.main_frame.show_error("Input Error", "Bundle name empty.")
return
# Ensure .bundle extension
if not bundle_name.lower().endswith(".bundle"):
bundle_name += ".bundle"
self.main_frame.bundle_name_entry.delete(0, tk.END)
self.main_frame.bundle_name_entry.insert(0, bundle_name)
bundle_full_path = os.path.join(usb_path, bundle_name)
# Get settings needed by action handler
current_settings = self._get_data_from_gui()
if not current_settings: return # Failed to get GUI data
backup_needed = current_settings.get("autobackup", False)
commit_needed = current_settings.get("autocommit", False)
commit_msg = current_settings.get("commit_message", "")
backup_dir = current_settings.get("backup_dir", "")
try:
# Parse exclusions needed for backup step within action handler
excluded_ext, excluded_dir = self._parse_exclusions(profile)
except ValueError as e:
self.main_frame.show_error("Config Error", str(e))
return
# Save settings before action
if not self.ui_save_settings():
self.logger.warning("Create Bundle: Failed to save settings first.")
# Ask user?
# Delegate execution to ActionHandler
try:
created_path = self.action_handler.execute_create_bundle(
svn_path=svn_path,
bundle_full_path=bundle_full_path,
profile_name=profile,
autobackup_enabled=backup_needed,
backup_base_dir=backup_dir,
autocommit_enabled=commit_needed,
commit_message=commit_msg,
excluded_extensions=excluded_ext,
excluded_dirs=excluded_dir
)
# Show feedback based on result
if created_path:
self.main_frame.show_info("Success", f"Bundle created:\n{created_path}")
else:
self.main_frame.show_warning("Info", "Bundle empty or not created.")
except Exception as e:
# Handle errors raised by action_handler
self.logger.error(f"Bundle process error: {e}", exc_info=True)
self.main_frame.show_error("Error", f"Failed:\n{e}")
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 selected.")
return
# Validate inputs
svn_path = self._get_and_validate_svn_path("Fetch Bundle")
if not svn_path: return
usb_path = self._get_and_validate_usb_path("Fetch Bundle")
if not usb_path: return
bundle_name = self.main_frame.bundle_updated_name_entry.get().strip()
if not bundle_name:
self.main_frame.show_error("Input Error", "Fetch bundle name empty.")
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
# 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
# Save settings before action
if not self.ui_save_settings():
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=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"Conflict occurred.\nResolve manually in:\n{svn_path}\nThen commit."
)
else:
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"Fetch process error: {e}", exc_info=True)
if isinstance(e, IOError) and "backup" in str(e).lower():
self.main_frame.show_error("Backup Error", f"Backup failed:\n{e}")
else:
self.main_frame.show_error("Error", f"Fetch failed:\n{e}")
def ui_manual_backup(self):
"""Callback for 'Backup Now' button."""
self.logger.info("--- Action Triggered: Manual Backup ---")
profile = self.main_frame.profile_var.get()
if not profile:
self.main_frame.show_error("Error", "No profile selected.")
return
# Validate paths
svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})")
if not svn_path: return
backup_dir = self.main_frame.backup_dir_var.get().strip()
if not backup_dir:
self.main_frame.show_error("Backup Error", "Backup directory empty.")
return
# Check/create backup dir
if not os.path.isdir(backup_dir):
create_q = f"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
# 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 by user.")
return
# Delegate backup creation to BackupHandler
try:
backup_path = self.backup_handler.create_zip_backup(
svn_path, backup_dir, profile, excluded_ext, excluded_dir
)
# Show success message if path is returned
if backup_path:
self.main_frame.show_info(
"Backup Complete", f"Backup created:\n{backup_path}"
)
else:
# Should not happen if exceptions are raised correctly
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}")
def ui_manual_commit(self):
"""Callback for the 'Commit' button."""
self.logger.info("--- Action Triggered: Manual Commit ---")
svn_path = self._get_and_validate_svn_path("Manual Commit")
if not svn_path: return
# Get commit message from GUI
commit_msg = self.main_frame.commit_message_var.get().strip()
if not commit_msg:
self.main_frame.show_error("Commit Error", "Commit message empty.")
return
# Save settings first? Optional.
if not self.ui_save_settings():
self.logger.warning("Manual Commit: Could not save settings.")
# Ask user?
# 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("Success", "Changes committed.")
# Optionally clear message field
# self.main_frame.commit_message_var.set("")
else:
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 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 ---")
svn_path = self._get_and_validate_svn_path("Create Tag")
if not svn_path: return
profile = self.main_frame.profile_var.get()
if not profile: self.main_frame.show_error("Error", "No profile."); return
# 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
if not self.ui_save_settings():
self.logger.warning("Create Tag: Could not save settings first.")
# Ask user?
# --- Open Dialog to get Tag Name and Tag Message ---
self.logger.debug("Opening create tag dialog...")
dialog = CreateTagDialog(self.master)
tag_info = dialog.result # Returns (tag_name, tag_message) or None
if not tag_info:
self.logger.info("Tag creation cancelled in dialog."); return
tag_name, tag_message = tag_info
self.logger.info(f"User provided tag: '{tag_name}', msg: '{tag_message}'")
# --- Delegate Execution to ActionHandler ---
try:
success = self.action_handler.execute_create_tag(
svn_path, commit_msg, tag_name, tag_message
)
# execute_create_tag raises exceptions on failure
if success: # Should be true if no exception
self.logger.info(f"Tag '{tag_name}' created successfully.")
self.main_frame.show_info("Success", f"Tag '{tag_name}' created.")
self.refresh_tag_list() # Update list
except ValueError as e: # Catch specific errors like "commit message required"
self.logger.error(f"Tag creation validation failed: {e}")
self.main_frame.show_error("Tag Error", str(e))
except GitCommandError as e: # Catch Git command errors (commit or tag)
self.logger.error(f"Tag creation failed (Git Error): {e}")
self.main_frame.show_error("Tag Error", f"Git command failed:\n{e}")
except Exception as e: # Catch unexpected errors
self.logger.exception(f"Unexpected error creating tag: {e}")
self.main_frame.show_error("Error", f"Unexpected error:\n{e}")
def ui_checkout_tag(self):
"""Callback for 'Checkout Selected Tag' button."""
self.logger.info("--- Action Triggered: Checkout Tag ---")
svn_path = self._get_and_validate_svn_path("Checkout Tag")
if not svn_path: return
selected_tag = self.main_frame.get_selected_tag() # Gets name
if not selected_tag:
self.main_frame.show_error("Selection Error", "Select a tag."); return
self.logger.info(f"Attempting checkout for tag: {selected_tag}")
# Confirmation dialog first
msg = (f"Checkout tag '{selected_tag}'?\n\n"
f"WARNINGS:\n- Files WILL BE OVERWRITTEN.\n- NO backup created.\n"
f"- Enters 'detached HEAD' state.")
if not self.main_frame.ask_yes_no("Confirm Checkout", msg):
self.logger.info("Tag checkout cancelled."); return
# Save settings before action? Optional.
if not self.ui_save_settings():
self.logger.warning("Checkout Tag: Could not save profile settings.")
# Delegate execution to ActionHandler
try:
success = self.action_handler.execute_checkout_tag(svn_path, selected_tag)
if success:
self.main_frame.show_info("Success",
f"Checked out tag '{selected_tag}'.\n\nNOTE: In 'detached HEAD'.")
# Update branch display after checkout
self.update_current_branch_display()
except ValueError as e: # Catch specific errors like "uncommitted changes"
self.logger.error(f"Checkout blocked: {e}")
self.main_frame.show_error("Checkout Blocked", str(e))
except GitCommandError as e: # Catch Git command errors
self.logger.error(f"Failed checkout tag '{selected_tag}': {e}")
self.main_frame.show_error("Checkout Error", f"Could not checkout:\n{e}")
except Exception as e: # Catch unexpected errors
self.logger.exception(f"Unexpected checkout error: {e}")
self.main_frame.show_error("Error", f"Unexpected checkout error:\n{e}")
# --- 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:
# 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 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 = "<N/A>" # 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 = "<Error>"
# 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 fields and disables most buttons."""
if hasattr(self, 'main_frame'):
mf = self.main_frame
mf.svn_path_entry.delete(0, tk.END)
mf.usb_path_entry.delete(0, tk.END)
mf.bundle_name_entry.delete(0, tk.END)
mf.bundle_updated_name_entry.delete(0, tk.END)
mf.commit_message_var.set("")
mf.autocommit_var.set(False)
mf.update_tag_list([])
mf.update_branch_list([])
mf.set_current_branch_display("<N/A>")
# Reset indicator and dependent widgets
self.update_svn_status_indicator("")
# Disable general buttons
self._disable_general_buttons()
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'):
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 buttons. State buttons rely on status update."""
if hasattr(self, 'main_frame'):
state = tk.NORMAL
names = ['create_bundle_button', 'fetch_bundle_button',
'manual_backup_button', 'save_settings_button']
for name in names:
widget = getattr(self.main_frame, name, None)
if widget: widget.config(state=state)
# Ensure state-dependent buttons reflect current status
self.update_svn_status_indicator(self.main_frame.svn_path_entry.get())
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 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 (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:
print("App init failed before GUI setup.")
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__":
# 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()