1324 lines
60 KiB
Python
1324 lines
60 KiB
Python
# 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, including the new dialog
|
|
from gui import MainFrame, GitignoreEditorWindow, CreateTagDialog
|
|
|
|
class GitSvnSyncApp:
|
|
"""
|
|
Main application class for the Git SVN Sync Tool.
|
|
Coordinates the GUI, configuration, and Git command execution.
|
|
"""
|
|
|
|
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")
|
|
# Handle window close event gracefully
|
|
master.protocol("WM_DELETE_WINDOW", self.on_closing)
|
|
|
|
# --- Early Logger Setup ---
|
|
# Basic config first in case setup_logger has issues
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(levelname)s - %(message)s"
|
|
)
|
|
# Get the application-specific logger instance
|
|
self.logger = logging.getLogger("GitSvnSyncApp")
|
|
|
|
# --- Configuration Manager ---
|
|
# Initialize ConfigManager
|
|
try:
|
|
self.config_manager = ConfigManager(self.logger)
|
|
except Exception as e:
|
|
self.logger.critical(
|
|
f"Failed to initialize ConfigManager: {e}",
|
|
exc_info=True
|
|
)
|
|
self.show_fatal_error(
|
|
f"Failed to load or create configuration file.\n{e}\n"
|
|
f"Application cannot continue."
|
|
)
|
|
# Ensure window is destroyed if initialization fails critically
|
|
master.destroy()
|
|
# Stop initialization if config fails
|
|
return
|
|
|
|
# --- GUI Main Frame ---
|
|
# Create the main GUI frame, passing required callbacks
|
|
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,
|
|
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,
|
|
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,
|
|
manual_backup_cb=self.manual_backup,
|
|
open_gitignore_editor_cb=self.open_gitignore_editor,
|
|
save_profile_cb=self.save_profile_settings, # Save button callback
|
|
# Pass Tag Management Callbacks
|
|
refresh_tags_cb=self.refresh_tag_list,
|
|
create_tag_cb=self.create_tag, # Triggers new flow
|
|
checkout_tag_cb=self.checkout_tag
|
|
)
|
|
except Exception as e:
|
|
self.logger.critical(
|
|
f"Failed to initialize MainFrame GUI: {e}",
|
|
exc_info=True
|
|
)
|
|
self.show_fatal_error(
|
|
f"Failed to create the main application window.\n{e}\n"
|
|
f"Application cannot continue."
|
|
)
|
|
master.destroy()
|
|
return
|
|
|
|
# --- Enhanced Logger Setup ---
|
|
# Configure logger using the GUI widget
|
|
self.logger = setup_logger(self.main_frame.log_text)
|
|
# Update ConfigManager's logger instance
|
|
self.config_manager.logger = self.logger
|
|
|
|
# --- Git Commands Handler ---
|
|
# Initialize GitCommands (decoupled from GUI)
|
|
self.git_commands = GitCommands(self.logger)
|
|
|
|
# --- Initial Application State ---
|
|
self.logger.info("Application initializing...")
|
|
# Load settings for the initially selected profile
|
|
# This is triggered by the trace on profile_var in MainFrame's __init__
|
|
initial_profile = self.main_frame.profile_var.get()
|
|
if initial_profile:
|
|
self.logger.debug(f"Initial profile: '{initial_profile}'. Loading...")
|
|
# load_profile_settings is called via trace and handles tag refresh
|
|
else:
|
|
self.logger.warning("No profile selected on startup.")
|
|
# Clear fields and disable buttons if no profile is selected
|
|
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."""
|
|
self.logger.info("Close button clicked. Preparing to exit.")
|
|
# TODO: Add checks for unsaved changes or running operations if needed
|
|
# For example, check if GitignoreEditorWindow is open and modified.
|
|
self.logger.info("Application closing.")
|
|
self.master.destroy()
|
|
|
|
|
|
# --- Profile Management Callbacks/Methods ---
|
|
|
|
def load_profile_settings(self, profile_name):
|
|
"""Loads settings for the specified profile into the GUI fields."""
|
|
self.logger.info(f"Loading settings for profile: '{profile_name}'")
|
|
|
|
# Handle case where no profile is selected (e.g., after removing last one)
|
|
if not profile_name:
|
|
self.logger.warning("Attempted load settings with no profile selected.")
|
|
self._clear_and_disable_fields()
|
|
return
|
|
|
|
# Check if the profile actually exists in the configuration
|
|
if profile_name not in self.config_manager.get_profile_sections():
|
|
self.logger.error(f"Profile '{profile_name}' not found in config.")
|
|
self.main_frame.show_error(
|
|
"Profile Error",
|
|
f"Profile '{profile_name}' not found."
|
|
)
|
|
self._clear_and_disable_fields()
|
|
return
|
|
|
|
# Load values using ConfigManager with appropriate fallbacks
|
|
cm = self.config_manager # Alias for brevity
|
|
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_name = cm.get_profile_option(profile_name, "bundle_name_updated", "")
|
|
autocommit_str = cm.get_profile_option(profile_name, "autocommit", "False")
|
|
commit_msg = cm.get_profile_option(profile_name, "commit_message", "")
|
|
autobackup_str = 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 Elements safely (check if main_frame exists)
|
|
if hasattr(self, 'main_frame'):
|
|
mf = self.main_frame # Alias
|
|
|
|
# Update Repository Frame widgets
|
|
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_name)
|
|
|
|
# Update Commit/Tag Frame widgets
|
|
mf.commit_message_var.set(commit_msg)
|
|
mf.autocommit_var.set(autocommit_str.lower() == "true")
|
|
|
|
# Update Backup Frame widgets
|
|
mf.autobackup_var.set(autobackup_str.lower() == "true")
|
|
mf.backup_dir_var.set(backup_dir)
|
|
mf.backup_exclude_extensions_var.set(excludes)
|
|
# Update state of backup dir entry based on checkbox
|
|
mf.toggle_backup_dir()
|
|
|
|
# Update status indicator and dependent buttons
|
|
self.update_svn_status_indicator(svn_path)
|
|
# Enable general function buttons
|
|
self._enable_function_buttons()
|
|
|
|
# Refresh tag list if the repo is valid and prepared
|
|
repo_is_ready = (
|
|
svn_path and
|
|
os.path.isdir(svn_path) and
|
|
os.path.exists(os.path.join(svn_path, ".git"))
|
|
)
|
|
if repo_is_ready:
|
|
self.refresh_tag_list()
|
|
else:
|
|
# Clear tag list if path invalid or repo not prepared
|
|
mf.update_tag_list([])
|
|
|
|
self.logger.info(f"Settings loaded successfully for '{profile_name}'.")
|
|
else:
|
|
self.logger.error("Cannot load settings: Main frame unavailable.")
|
|
|
|
|
|
def save_profile_settings(self):
|
|
"""Saves the current GUI field values to the selected profile."""
|
|
profile = self.main_frame.profile_var.get()
|
|
if not profile:
|
|
self.logger.warning("Cannot save settings: No profile selected.")
|
|
# Show error only if explicitly triggered by user via Save button?
|
|
# self.main_frame.show_error("Save Error", "No profile selected.")
|
|
return False # Indicate failure
|
|
|
|
self.logger.info(f"Saving settings for profile: '{profile}'")
|
|
try:
|
|
cm = self.config_manager # Alias
|
|
mf = self.main_frame # Alias
|
|
|
|
# Save Repository settings
|
|
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())
|
|
|
|
# Save Commit/Tag settings
|
|
cm.set_profile_option(profile, "autocommit",
|
|
str(mf.autocommit_var.get()))
|
|
cm.set_profile_option(profile, "commit_message",
|
|
mf.commit_message_var.get())
|
|
|
|
# Save Backup settings
|
|
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())
|
|
|
|
# Persist changes to the configuration file
|
|
cm.save_config()
|
|
|
|
self.logger.info(f"Profile settings for '{profile}' saved successfully.")
|
|
# Optionally provide visual feedback on save success
|
|
# self.main_frame.show_info("Saved", f"Settings saved for '{profile}'.")
|
|
return True # Indicate success
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error saving settings for '{profile}': {e}",
|
|
exc_info=True)
|
|
self.main_frame.show_error("Save Error",
|
|
f"Failed to save settings:\n{e}")
|
|
return False # Indicate failure
|
|
|
|
|
|
def add_profile(self):
|
|
"""Handles adding a new profile."""
|
|
self.logger.debug("'Add Profile' button clicked.")
|
|
# Get new profile name from user via dialog
|
|
new_profile_name = self.main_frame.ask_new_profile_name()
|
|
|
|
if not new_profile_name:
|
|
# User cancelled the dialog
|
|
self.logger.info("Profile addition cancelled by user.")
|
|
return
|
|
|
|
new_profile_name = new_profile_name.strip()
|
|
if not new_profile_name:
|
|
# Empty name provided
|
|
self.logger.warning("Attempted to add profile with empty name.")
|
|
self.main_frame.show_error("Error", "Profile name cannot be empty.")
|
|
return
|
|
|
|
# Check if profile name already exists
|
|
if new_profile_name in self.config_manager.get_profile_sections():
|
|
self.logger.warning(f"Profile name already exists: '{new_profile_name}'")
|
|
self.main_frame.show_error(
|
|
"Error",
|
|
f"Profile name '{new_profile_name}' already exists."
|
|
)
|
|
return
|
|
|
|
# Proceed with adding the profile
|
|
self.logger.info(f"Adding new profile: '{new_profile_name}'")
|
|
try:
|
|
# Get default values for all keys
|
|
defaults = self.config_manager._get_expected_keys_with_defaults()
|
|
# Customize defaults specific to a new profile
|
|
defaults["bundle_name"] = f"{new_profile_name}_repo.bundle"
|
|
defaults["bundle_name_updated"] = f"{new_profile_name}_update.bundle"
|
|
defaults["svn_working_copy_path"] = "" # Start with empty paths
|
|
defaults["usb_drive_path"] = "" # Start with empty paths
|
|
|
|
# Set all default options for the new profile
|
|
for key, value in defaults.items():
|
|
self.config_manager.set_profile_option(new_profile_name, key, value)
|
|
|
|
# Save the updated configuration
|
|
self.config_manager.save_config()
|
|
|
|
# Update the GUI dropdown list
|
|
updated_sections = self.config_manager.get_profile_sections()
|
|
self.main_frame.update_profile_dropdown(updated_sections)
|
|
# Select the newly added profile in the dropdown
|
|
# This will trigger load_profile_settings via the trace
|
|
self.main_frame.profile_var.set(new_profile_name)
|
|
|
|
self.logger.info(f"Profile '{new_profile_name}' added successfully.")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error adding profile '{new_profile_name}': {e}",
|
|
exc_info=True)
|
|
self.main_frame.show_error("Error", f"Failed to add profile:\n{e}")
|
|
|
|
|
|
def remove_profile(self):
|
|
"""Handles removing the currently selected profile."""
|
|
self.logger.debug("'Remove Profile' button clicked.")
|
|
profile_to_remove = self.main_frame.profile_var.get()
|
|
|
|
if not profile_to_remove:
|
|
self.logger.warning("Attempted remove when no profile selected.")
|
|
self.main_frame.show_error("Error", "No profile selected to remove.")
|
|
return
|
|
|
|
# Prevent removing the default profile
|
|
if profile_to_remove == DEFAULT_PROFILE:
|
|
self.logger.warning("Attempted to remove the default profile.")
|
|
self.main_frame.show_error(
|
|
"Error", f"Cannot remove the '{DEFAULT_PROFILE}' profile."
|
|
)
|
|
return
|
|
|
|
# Confirmation dialog
|
|
confirm_msg = (f"Are you sure you want to permanently remove profile "
|
|
f"'{profile_to_remove}'?")
|
|
if self.main_frame.ask_yes_no("Remove Profile", confirm_msg):
|
|
self.logger.info(f"Attempting remove profile: '{profile_to_remove}'")
|
|
try:
|
|
# Remove section via ConfigManager
|
|
success = self.config_manager.remove_profile_section(
|
|
profile_to_remove
|
|
)
|
|
if success:
|
|
self.config_manager.save_config() # Save changes
|
|
self.logger.info("Profile removed successfully.")
|
|
# Update dropdown - new profile selection triggers load
|
|
updated_sections = self.config_manager.get_profile_sections()
|
|
self.main_frame.update_profile_dropdown(updated_sections)
|
|
else:
|
|
# ConfigManager should have logged reason
|
|
self.main_frame.show_error(
|
|
"Error",
|
|
f"Failed to remove profile '{profile_to_remove}'. See logs."
|
|
)
|
|
|
|
except Exception as e:
|
|
self.logger.error(
|
|
f"Unexpected error removing profile '{profile_to_remove}': {e}",
|
|
exc_info=True
|
|
)
|
|
self.main_frame.show_error(
|
|
"Error",
|
|
f"An unexpected error occurred removing profile:\n{e}"
|
|
)
|
|
else:
|
|
# User clicked 'No' in the confirmation dialog
|
|
self.logger.info("Profile removal cancelled by user.")
|
|
|
|
|
|
# --- GUI Interaction Callbacks ---
|
|
|
|
def browse_folder(self, entry_widget):
|
|
"""Opens folder dialog and updates the specified Tkinter Entry."""
|
|
self.logger.debug("Browse folder requested.")
|
|
# Determine initial directory for dialog
|
|
current_path = entry_widget.get()
|
|
# Suggest current path if valid, else user's home directory
|
|
initial_dir = current_path if os.path.isdir(current_path) else \
|
|
os.path.expanduser("~")
|
|
|
|
# Show folder selection dialog
|
|
directory = filedialog.askdirectory(
|
|
initialdir=initial_dir,
|
|
title="Select Directory",
|
|
parent=self.master # Make dialog modal to main window
|
|
)
|
|
|
|
if directory:
|
|
# User selected a directory
|
|
self.logger.debug(f"Directory selected: {directory}")
|
|
# Update the entry widget's content
|
|
entry_widget.delete(0, tk.END)
|
|
entry_widget.insert(0, directory)
|
|
# If the SVN path entry was changed, trigger status update
|
|
if entry_widget == self.main_frame.svn_path_entry:
|
|
self.update_svn_status_indicator(directory)
|
|
else:
|
|
# User cancelled the dialog
|
|
self.logger.debug("Folder browse dialog cancelled.")
|
|
|
|
|
|
def update_svn_status_indicator(self, svn_path):
|
|
"""
|
|
Checks repo status, updates indicator, and enables/disables
|
|
Prepare, Edit Gitignore, and Commit/Tag widgets.
|
|
"""
|
|
# Determine directory validity and Git preparation status
|
|
is_valid_dir = bool(svn_path and os.path.isdir(svn_path))
|
|
is_prepared = False
|
|
if is_valid_dir:
|
|
git_dir_path = os.path.join(svn_path, ".git")
|
|
is_prepared = os.path.exists(git_dir_path)
|
|
|
|
self.logger.debug(
|
|
f"Updating status for '{svn_path}'. Valid: {is_valid_dir}, "
|
|
f"Prepared: {is_prepared}"
|
|
)
|
|
|
|
# Update GUI elements safely
|
|
if hasattr(self, 'main_frame'):
|
|
mf = self.main_frame # Alias
|
|
|
|
# Update indicator and Prepare button via MainFrame method
|
|
mf.update_svn_indicator(is_prepared)
|
|
|
|
# Determine state for other dependent widgets
|
|
# Edit Gitignore button needs a valid directory path
|
|
gitignore_state = tk.NORMAL if is_valid_dir else tk.DISABLED
|
|
# Commit/Tag widgets need a prepared Git repository
|
|
commit_tag_state = tk.NORMAL if is_prepared else tk.DISABLED
|
|
|
|
# Update Edit Gitignore button state
|
|
if hasattr(mf, 'edit_gitignore_button'):
|
|
mf.edit_gitignore_button.config(state=gitignore_state)
|
|
|
|
# Update Commit/Tag section widgets state
|
|
if hasattr(mf, 'commit_message_entry'):
|
|
mf.commit_message_entry.config(state=commit_tag_state)
|
|
if hasattr(mf, 'autocommit_checkbox'):
|
|
mf.autocommit_checkbox.config(state=commit_tag_state)
|
|
if hasattr(mf, 'refresh_tags_button'):
|
|
mf.refresh_tags_button.config(state=commit_tag_state)
|
|
if hasattr(mf, 'create_tag_button'):
|
|
mf.create_tag_button.config(state=commit_tag_state)
|
|
if hasattr(mf, 'checkout_tag_button'):
|
|
mf.checkout_tag_button.config(state=commit_tag_state)
|
|
|
|
|
|
def open_gitignore_editor(self):
|
|
"""Opens the editor window for the .gitignore file."""
|
|
self.logger.info("--- Action: Edit .gitignore ---")
|
|
# Validate the SVN Path first
|
|
svn_path = self._get_and_validate_svn_path("Edit .gitignore")
|
|
if not svn_path:
|
|
return # Stop if path is invalid
|
|
|
|
# Construct the path to .gitignore
|
|
gitignore_path = os.path.join(svn_path, ".gitignore")
|
|
self.logger.debug(f"Target .gitignore path: {gitignore_path}")
|
|
|
|
# Open the Editor Window
|
|
try:
|
|
# Create and run the modal editor window
|
|
editor = GitignoreEditorWindow(self.master, gitignore_path, self.logger)
|
|
self.logger.debug("Gitignore editor window opened.")
|
|
# Execution blocks here until the editor window is closed
|
|
|
|
except Exception as e:
|
|
# Handle errors during editor creation/opening
|
|
self.logger.exception(f"Error opening .gitignore editor: {e}")
|
|
self.main_frame.show_error(
|
|
"Editor Error",
|
|
f"Could not open the .gitignore editor:\n{e}"
|
|
)
|
|
|
|
|
|
# --- Core Functionality Methods ---
|
|
|
|
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.")
|
|
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.")
|
|
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}"
|
|
)
|
|
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."""
|
|
# 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.")
|
|
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.")
|
|
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}"
|
|
)
|
|
return None
|
|
|
|
self.logger.debug(f"{operation_name}: Validated Bundle Target path: {abs_path}")
|
|
return abs_path
|
|
|
|
|
|
def prepare_svn_for_git(self):
|
|
"""Handles the 'Prepare SVN for Git' action."""
|
|
self.logger.info("--- Action: Prepare SVN Repo ---")
|
|
svn_path = self._get_and_validate_svn_path("Prepare SVN")
|
|
if not svn_path:
|
|
return # Validation failed
|
|
|
|
# Save settings *before* potentially modifying .gitignore
|
|
if not self.save_profile_settings():
|
|
self.logger.warning("Prepare SVN: Could not save profile settings.")
|
|
# Consider asking user if they want to continue
|
|
|
|
# Check if already prepared to avoid redundant operations
|
|
git_dir_path = os.path.join(svn_path, ".git")
|
|
if os.path.exists(git_dir_path):
|
|
self.logger.info(f"Repository already prepared: {svn_path}")
|
|
self.main_frame.show_info("Already Prepared",
|
|
"Repository already prepared.")
|
|
self.update_svn_status_indicator(svn_path) # Ensure UI state correct
|
|
return
|
|
|
|
# Execute Preparation command
|
|
self.logger.info(f"Executing preparation for: {svn_path}")
|
|
try:
|
|
self.git_commands.prepare_svn_for_git(svn_path)
|
|
self.logger.info("Repository prepared successfully.")
|
|
self.main_frame.show_info("Success", "Repository prepared.")
|
|
# Update indicator and dependent buttons
|
|
self.update_svn_status_indicator(svn_path)
|
|
except (GitCommandError, ValueError) as e:
|
|
# Handle known errors
|
|
self.logger.error(f"Error preparing repository: {e}")
|
|
self.main_frame.show_error("Preparation Error", f"Failed:\n{e}")
|
|
self.update_svn_status_indicator(svn_path) # Update state
|
|
except Exception as e:
|
|
# Handle unexpected errors
|
|
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 create_git_bundle(self):
|
|
"""Handles the 'Create Bundle' action."""
|
|
self.logger.info("--- Action: Create Git Bundle ---")
|
|
profile = self.main_frame.profile_var.get()
|
|
if not profile:
|
|
self.logger.error("Create Bundle: No profile selected.")
|
|
self.main_frame.show_error("Error", "No profile selected.")
|
|
return
|
|
|
|
# Validate paths and bundle name
|
|
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.logger.error("Create Bundle: Bundle name is empty.")
|
|
self.main_frame.show_error("Input Error", "Bundle name is empty.")
|
|
return
|
|
# Ensure .bundle extension
|
|
if not bundle_name.lower().endswith(".bundle"):
|
|
self.logger.warning(f"Adding .bundle extension to '{bundle_name}'.")
|
|
bundle_name += ".bundle"
|
|
# Update the GUI field to reflect the change
|
|
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)
|
|
self.logger.debug(f"Target bundle file: {bundle_full_path}")
|
|
|
|
# Save current profile settings before proceeding
|
|
if not self.save_profile_settings():
|
|
self.logger.warning("Create Bundle: Could not save settings.")
|
|
# Ask user?
|
|
|
|
# --- Backup Step ---
|
|
if self.main_frame.autobackup_var.get():
|
|
self.logger.info("Autobackup enabled. Starting backup...")
|
|
backup_success = self.create_backup(svn_path, profile)
|
|
if not backup_success:
|
|
self.logger.error("Aborted bundle creation: Backup failed.")
|
|
return # Stop if backup fails
|
|
|
|
# --- Autocommit Step (if checkbox enabled) ---
|
|
if self.main_frame.autocommit_var.get():
|
|
self.logger.info("Autocommit before bundle is enabled.")
|
|
try:
|
|
# Check for changes first
|
|
has_changes = self.git_commands.git_status_has_changes(svn_path)
|
|
if has_changes:
|
|
self.logger.info("Changes detected, performing autocommit...")
|
|
# Use message from Commit/Tag frame or default
|
|
custom_message = self.main_frame.commit_message_var.get().strip()
|
|
commit_msg = custom_message if custom_message else \
|
|
f"Autocommit profile '{profile}' before bundle"
|
|
self.logger.debug(f"Using commit message: '{commit_msg}'")
|
|
# Perform the commit
|
|
commit_made = self.git_commands.git_commit(svn_path, commit_msg)
|
|
if commit_made:
|
|
self.logger.info("Autocommit successful.")
|
|
# else: git_commit logs 'nothing to commit'
|
|
else:
|
|
self.logger.info("No changes detected for autocommit.")
|
|
except (GitCommandError, ValueError) as e:
|
|
self.logger.error(f"Autocommit error: {e}")
|
|
self.main_frame.show_error("Autocommit Error", f"Failed:\n{e}")
|
|
return # Stop process if commit fails
|
|
except Exception as e:
|
|
self.logger.exception(f"Unexpected autocommit error: {e}")
|
|
self.main_frame.show_error("Error", f"Unexpected commit error:\n{e}")
|
|
return
|
|
|
|
# --- Create Bundle Step ---
|
|
self.logger.info(f"Creating bundle file: {bundle_full_path}")
|
|
try:
|
|
# Execute the bundle creation command
|
|
self.git_commands.create_git_bundle(svn_path, bundle_full_path)
|
|
# Verify bundle creation success (exists and not empty)
|
|
bundle_exists = os.path.exists(bundle_full_path)
|
|
bundle_not_empty = bundle_exists and os.path.getsize(bundle_full_path) > 0
|
|
if bundle_exists and bundle_not_empty:
|
|
self.logger.info("Git bundle created successfully.")
|
|
self.main_frame.show_info("Success",
|
|
f"Bundle created:\n{bundle_full_path}")
|
|
else:
|
|
# Bundle likely empty or command had non-fatal warning
|
|
self.logger.warning("Bundle file not created or is empty.")
|
|
self.main_frame.show_warning(
|
|
"Bundle Not Created",
|
|
"Bundle empty or not created.\n(Likely no new commits)."
|
|
)
|
|
# Clean up empty file if it exists
|
|
if bundle_exists and not bundle_not_empty:
|
|
try:
|
|
os.remove(bundle_full_path)
|
|
self.logger.info("Removed empty bundle file.")
|
|
except OSError:
|
|
self.logger.warning("Could not remove empty bundle file.")
|
|
|
|
except (GitCommandError, ValueError) as e:
|
|
self.logger.error(f"Error creating Git bundle: {e}")
|
|
self.main_frame.show_error("Error", f"Failed create bundle:\n{e}")
|
|
except Exception as e:
|
|
self.logger.exception(f"Unexpected error during bundle creation: {e}")
|
|
self.main_frame.show_error("Error", f"Unexpected bundle error:\n{e}")
|
|
|
|
|
|
def fetch_from_git_bundle(self):
|
|
"""Handles the 'Fetch from Bundle' action."""
|
|
self.logger.info("--- Action: Fetch from Git Bundle ---")
|
|
profile = self.main_frame.profile_var.get()
|
|
if not profile:
|
|
self.logger.error("Fetch: No profile selected.")
|
|
self.main_frame.show_error("Error", "No profile selected.")
|
|
return
|
|
|
|
# Validate paths and bundle name
|
|
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.logger.error("Fetch: Fetch bundle name empty.")
|
|
self.main_frame.show_error("Input Error", "Fetch bundle name empty.")
|
|
return
|
|
|
|
bundle_full_path = os.path.join(usb_path, bundle_name)
|
|
self.logger.debug(f"Source bundle file: {bundle_full_path}")
|
|
|
|
# Check if Bundle File Exists
|
|
if not os.path.isfile(bundle_full_path):
|
|
self.logger.error(f"Fetch: Bundle file not found: {bundle_full_path}")
|
|
self.main_frame.show_error(
|
|
"File Not Found",
|
|
f"Bundle file not found:\n{bundle_full_path}"
|
|
)
|
|
return
|
|
|
|
# Save settings before potentially changing repo state
|
|
if not self.save_profile_settings():
|
|
self.logger.warning("Fetch: Could not save profile settings.")
|
|
# Ask user?
|
|
|
|
# --- Backup Step ---
|
|
if self.main_frame.autobackup_var.get():
|
|
self.logger.info("Autobackup enabled. Starting backup...")
|
|
backup_success = self.create_backup(svn_path, profile)
|
|
if not backup_success:
|
|
self.logger.error("Aborted fetch: Backup failed.")
|
|
return
|
|
|
|
# --- Fetch and Merge Step ---
|
|
self.logger.info(f"Fetching into '{svn_path}' from: {bundle_full_path}")
|
|
try:
|
|
# Execute the fetch/merge command
|
|
self.git_commands.fetch_from_git_bundle(svn_path, bundle_full_path)
|
|
self.logger.info("Fetch/merge process completed.")
|
|
# Inform user, acknowledging potential conflicts
|
|
self.main_frame.show_info(
|
|
"Fetch Complete",
|
|
f"Fetch complete.\nCheck logs for merge status/conflicts."
|
|
)
|
|
|
|
except GitCommandError as e:
|
|
# Handle specific errors like merge conflicts
|
|
self.logger.error(f"Error fetching/merging: {e}")
|
|
if "merge conflict" in str(e).lower():
|
|
self.main_frame.show_error(
|
|
"Merge Conflict",
|
|
f"Merge conflict occurred.\nResolve manually in:\n{svn_path}\n"
|
|
f"Then run 'git add .' and 'git commit'."
|
|
)
|
|
else:
|
|
# Show other Git command errors
|
|
self.main_frame.show_error("Fetch/Merge Error", f"Failed:\n{e}")
|
|
except ValueError as e:
|
|
# Handle validation errors passed up
|
|
self.logger.error(f"Validation error during fetch: {e}")
|
|
self.main_frame.show_error("Input Error", f"Invalid input:\n{e}")
|
|
except Exception as e:
|
|
# Handle unexpected errors
|
|
self.logger.exception(f"Unexpected error during fetch/merge: {e}")
|
|
self.main_frame.show_error("Error", f"Unexpected fetch error:\n{e}")
|
|
|
|
|
|
# --- Backup Logic ---
|
|
def _parse_exclusions(self, profile_name):
|
|
"""Parses exclusion string from config into sets."""
|
|
exclude_str = self.config_manager.get_profile_option(
|
|
profile_name, "backup_exclude_extensions", fallback=""
|
|
)
|
|
excluded_extensions = set()
|
|
# Define standard directories to always exclude (lowercase for comparison)
|
|
excluded_dirs_base = {".git", ".svn"}
|
|
|
|
if exclude_str:
|
|
# Split by comma, clean up each part
|
|
raw_extensions = exclude_str.split(',')
|
|
for ext in raw_extensions:
|
|
clean_ext = ext.strip().lower()
|
|
if clean_ext:
|
|
# Ensure extension starts with a dot
|
|
if not clean_ext.startswith('.'):
|
|
clean_ext = '.' + clean_ext
|
|
excluded_extensions.add(clean_ext)
|
|
|
|
self.logger.debug(
|
|
f"Parsed Exclusions '{profile_name}' - "
|
|
f"Ext: {excluded_extensions}, Dirs: {excluded_dirs_base}"
|
|
)
|
|
return excluded_extensions, excluded_dirs_base
|
|
|
|
|
|
def create_backup(self, source_repo_path, profile_name):
|
|
"""Creates a timestamped ZIP backup, respecting profile exclusions."""
|
|
self.logger.info(
|
|
f"Creating ZIP backup for '{profile_name}' from '{source_repo_path}'"
|
|
)
|
|
|
|
# Get and Validate Backup Destination Directory
|
|
backup_base_dir = self.main_frame.backup_dir_var.get().strip()
|
|
if not backup_base_dir:
|
|
self.logger.error("Backup Fail: Backup directory empty.")
|
|
self.main_frame.show_error("Backup Error", "Backup directory empty.")
|
|
return False
|
|
# Ensure directory exists, create if necessary
|
|
if not os.path.isdir(backup_base_dir):
|
|
self.logger.info(f"Creating backup dir: {backup_base_dir}")
|
|
try:
|
|
os.makedirs(backup_base_dir, exist_ok=True)
|
|
except OSError as e:
|
|
self.logger.error(f"Cannot create backup dir: {e}", exc_info=True)
|
|
self.main_frame.show_error("Backup Error", f"Cannot create dir:\n{e}")
|
|
return False
|
|
|
|
# Parse Exclusions for the profile
|
|
try:
|
|
excluded_extensions, excluded_dirs_base = self._parse_exclusions(profile_name)
|
|
except Exception as e:
|
|
self.logger.error(f"Failed parse exclusions: {e}", exc_info=True)
|
|
self.main_frame.show_error("Backup Error", "Cannot parse exclusions.")
|
|
return False
|
|
|
|
# Construct Backup Filename
|
|
now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
# Sanitize profile name for use in filename
|
|
safe_profile = "".join(c for c in profile_name
|
|
if c.isalnum() or c in '_-').rstrip() or "profile"
|
|
backup_filename = f"{now_str}_backup_{safe_profile}.zip"
|
|
backup_full_path = os.path.join(backup_base_dir, backup_filename)
|
|
self.logger.info(f"Target backup ZIP file: {backup_full_path}")
|
|
|
|
# Create ZIP Archive
|
|
files_added = 0
|
|
files_excluded = 0
|
|
dirs_excluded = 0
|
|
zip_f = None # Initialize zip file object
|
|
try:
|
|
# Open ZIP file with appropriate settings
|
|
zip_f = zipfile.ZipFile(backup_full_path, 'w',
|
|
compression=zipfile.ZIP_DEFLATED,
|
|
allowZip64=True) # Support large archives
|
|
|
|
# Walk through the source directory
|
|
for root, dirs, files in os.walk(source_repo_path, topdown=True):
|
|
# --- Directory Exclusion ---
|
|
original_dirs = list(dirs) # Copy before modifying
|
|
# Exclude based on base name (case-insensitive)
|
|
dirs[:] = [d for d in dirs if d.lower() not in excluded_dirs_base]
|
|
# Log excluded directories for this level
|
|
excluded_dirs_now = set(original_dirs) - set(dirs)
|
|
if excluded_dirs_now:
|
|
dirs_excluded += len(excluded_dirs_now)
|
|
for ex_dir in excluded_dirs_now:
|
|
path_excluded = os.path.join(root, ex_dir)
|
|
self.logger.debug(f"Excluding directory: {path_excluded}")
|
|
|
|
# --- File Exclusion and Addition ---
|
|
for filename in files:
|
|
# Get file extension (lowercase for comparison)
|
|
_, file_ext = os.path.splitext(filename)
|
|
file_ext_lower = file_ext.lower()
|
|
|
|
# Check exclusion rules (filename matches dir OR extension matches)
|
|
# Case-insensitive check for filename matching excluded dirs
|
|
if filename.lower() in excluded_dirs_base or \
|
|
file_ext_lower in excluded_extensions:
|
|
path_excluded = os.path.join(root, filename)
|
|
self.logger.debug(f"Excluding file: {path_excluded}")
|
|
files_excluded += 1
|
|
continue # Skip this file
|
|
|
|
# If not excluded, add file to ZIP
|
|
file_full_path = os.path.join(root, filename)
|
|
# Store with relative path inside ZIP archive
|
|
archive_name = os.path.relpath(file_full_path, source_repo_path)
|
|
|
|
try:
|
|
zip_f.write(file_full_path, arcname=archive_name)
|
|
files_added += 1
|
|
# Log progress occasionally for large backups
|
|
if files_added % 500 == 0:
|
|
self.logger.debug(f"Added {files_added} files...")
|
|
except Exception as write_e:
|
|
# Log error writing specific file but continue backup process
|
|
self.logger.error(
|
|
f"Error writing file '{file_full_path}' to ZIP: {write_e}",
|
|
exc_info=True
|
|
)
|
|
|
|
# Log final summary after successful walk and write attempts
|
|
self.logger.info(f"Backup ZIP creation finished: {backup_full_path}")
|
|
self.logger.info(
|
|
f"Summary - Added: {files_added}, Excl Files: {files_excluded}, "
|
|
f"Excl Dirs: {dirs_excluded}"
|
|
)
|
|
return True # Indicate backup process completed
|
|
|
|
except OSError as e:
|
|
# Handle OS-level errors (permissions, disk space, etc.)
|
|
self.logger.error(f"OS error during backup ZIP creation: {e}",
|
|
exc_info=True)
|
|
self.main_frame.show_error("Backup Error", f"OS Error creating ZIP:\n{e}")
|
|
return False
|
|
except zipfile.BadZipFile as e:
|
|
# Handle errors related to the ZIP file format itself
|
|
self.logger.error(f"ZIP format error during backup: {e}",
|
|
exc_info=True)
|
|
self.main_frame.show_error("Backup Error", f"ZIP format error:\n{e}")
|
|
return False
|
|
except Exception as e:
|
|
# Catch any other unexpected error during the process
|
|
self.logger.exception(f"Unexpected error during ZIP backup: {e}")
|
|
self.main_frame.show_error("Backup Error",
|
|
f"Unexpected ZIP error:\n{e}")
|
|
return False
|
|
finally:
|
|
# Ensure the ZIP file is always closed, even if errors occurred
|
|
if zip_f:
|
|
zip_f.close()
|
|
self.logger.debug(f"ZIP file '{backup_full_path}' closed.")
|
|
# Clean up potentially empty/failed ZIP file
|
|
# Check if file exists and if any files were actually added
|
|
if os.path.exists(backup_full_path) and files_added == 0:
|
|
self.logger.warning(f"Backup ZIP is empty: {backup_full_path}")
|
|
try:
|
|
# Attempt to remove the empty zip file
|
|
os.remove(backup_full_path)
|
|
self.logger.info("Removed empty backup ZIP file.")
|
|
except OSError as rm_e:
|
|
# Log error if removal fails
|
|
self.logger.error(f"Failed remove empty backup ZIP: {rm_e}")
|
|
|
|
|
|
def manual_backup(self):
|
|
"""Handles the 'Backup Now' button click."""
|
|
self.logger.info("--- Action: Manual Backup Now ---")
|
|
profile = self.main_frame.profile_var.get()
|
|
if not profile:
|
|
self.logger.warning("Manual Backup: No profile selected.")
|
|
self.main_frame.show_error("Backup Error", "No profile selected.")
|
|
return
|
|
|
|
# Validate SVN Path
|
|
svn_path = self._get_and_validate_svn_path(f"Manual Backup ({profile})")
|
|
if not svn_path:
|
|
return # Validation failed
|
|
|
|
# Save current settings first (especially backup dir and exclusions)
|
|
self.logger.info("Saving settings before manual backup...")
|
|
if not self.save_profile_settings():
|
|
self.logger.error("Manual Backup: Could not save settings.")
|
|
# Ask user if they want to continue with potentially old settings
|
|
if not self.main_frame.ask_yes_no(
|
|
"Save Error",
|
|
"Could not save current settings (e.g., exclusions).\n"
|
|
"Continue backup with previously saved settings?"
|
|
):
|
|
self.logger.warning("Manual backup aborted by user.")
|
|
return
|
|
|
|
# Call the create_backup method (which handles ZIP creation)
|
|
self.logger.info(f"Starting manual backup for profile '{profile}'...")
|
|
success = self.create_backup(svn_path, profile)
|
|
|
|
# Show result message to the user
|
|
if success:
|
|
self.main_frame.show_info("Backup Complete",
|
|
"Manual ZIP backup completed successfully.")
|
|
else:
|
|
# Error message should have been shown by create_backup
|
|
self.logger.error(f"Manual backup failed for profile '{profile}'.")
|
|
# Optionally show a redundant message here if needed
|
|
|
|
|
|
# --- Tag Management Methods ---
|
|
|
|
def refresh_tag_list(self):
|
|
"""Fetches tags with subjects and updates the GUI listbox."""
|
|
self.logger.info("--- Action: Refresh Tag List ---")
|
|
svn_path = self._get_and_validate_svn_path("Refresh Tags")
|
|
if not svn_path:
|
|
# Clear list if path invalid
|
|
if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([])
|
|
return
|
|
|
|
# Check if repository is prepared (Git commands require .git)
|
|
git_dir_path = os.path.join(svn_path, ".git")
|
|
if not os.path.exists(git_dir_path):
|
|
self.logger.warning("Cannot refresh tags: Repository not prepared.")
|
|
# Clear list and potentially show warning? UI state should reflect.
|
|
if hasattr(self, 'main_frame'): self.main_frame.update_tag_list([])
|
|
return
|
|
|
|
# Fetch tags and update GUI
|
|
try:
|
|
# list_tags now returns list of tuples (name, subject)
|
|
tags_data = self.git_commands.list_tags(svn_path)
|
|
if hasattr(self, 'main_frame'):
|
|
self.main_frame.update_tag_list(tags_data)
|
|
self.logger.info(f"Tag list updated ({len(tags_data)} tags found).")
|
|
except Exception as e:
|
|
# Catch potential errors from list_tags or GUI update
|
|
self.logger.error(f"Failed to retrieve or update tag list: {e}",
|
|
exc_info=True)
|
|
self.main_frame.show_error("Error", f"Could not refresh tags:\n{e}")
|
|
if hasattr(self, 'main_frame'):
|
|
# Clear list on error
|
|
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()
|
|
if not profile:
|
|
self.logger.error("Create Tag: No profile selected.")
|
|
self.main_frame.show_error("Error", "No profile selected.")
|
|
return
|
|
|
|
# Save current settings (especially commit message) before proceeding
|
|
if not self.save_profile_settings():
|
|
self.logger.warning("Create Tag: Could not save settings first.")
|
|
# Ask user?
|
|
|
|
# --- Step 1: Check for changes and commit IF commit message provided ---
|
|
try:
|
|
has_changes = self.git_commands.git_status_has_changes(svn_path)
|
|
if has_changes:
|
|
self.logger.info("Uncommitted changes detected before tagging.")
|
|
# Get commit message from the dedicated GUI field
|
|
commit_msg = self.main_frame.commit_message_var.get().strip()
|
|
if not commit_msg:
|
|
# Block tag creation if changes exist but no message
|
|
self.logger.error(
|
|
"Create Tag blocked: Changes exist but no commit message."
|
|
)
|
|
self.main_frame.show_error(
|
|
"Commit Required",
|
|
"Uncommitted changes exist.\nPlease enter a commit message "
|
|
"in the 'Commit / Tag Management' section and try again."
|
|
)
|
|
return # Stop the process
|
|
|
|
# Confirm commit with the user using the provided message
|
|
confirm_commit_msg = (
|
|
f"Commit current changes with message:\n'{commit_msg}'?"
|
|
)
|
|
if not self.main_frame.ask_yes_no("Confirm Commit",
|
|
confirm_commit_msg):
|
|
self.logger.info("User cancelled commit before tagging.")
|
|
self.main_frame.show_warning("Cancelled",
|
|
"Tag creation cancelled.")
|
|
return # Stop tagging
|
|
|
|
# Perform the commit
|
|
commit_made = self.git_commands.git_commit(svn_path, commit_msg)
|
|
if commit_made:
|
|
self.logger.info("Pre-tag commit successful.")
|
|
# Clear the commit message box after successful commit?
|
|
# self.main_frame.commit_message_var.set("")
|
|
# else: git_commit logs 'nothing to commit', proceed anyway
|
|
|
|
else:
|
|
# No changes, proceed directly to getting tag info
|
|
self.logger.info("No uncommitted changes detected before tagging.")
|
|
|
|
except (GitCommandError, ValueError) as e:
|
|
# Handle errors during status check or commit
|
|
self.logger.error(f"Error committing before tag: {e}")
|
|
self.main_frame.show_error("Commit Error", f"Failed commit:\n{e}")
|
|
return # Stop if commit failed
|
|
except Exception as e:
|
|
# Handle unexpected errors
|
|
self.logger.exception(f"Unexpected pre-tag commit error: {e}")
|
|
self.main_frame.show_error("Error", f"Unexpected commit error:\n{e}")
|
|
return
|
|
|
|
# --- Step 2: Open Dialog for Tag Name and Tag Message ---
|
|
self.logger.debug("Opening create tag dialog...")
|
|
# Use the custom dialog from gui.py
|
|
dialog = CreateTagDialog(self.master) # Parent is the main Tk window
|
|
tag_info = dialog.result # Returns tuple (name, message) or None
|
|
|
|
# --- Step 3: Create Tag if user confirmed dialog ---
|
|
if tag_info:
|
|
tag_name, tag_message = tag_info
|
|
self.logger.info(f"User wants tag: '{tag_name}', msg: '{tag_message}'")
|
|
try:
|
|
# Execute tag creation command
|
|
self.git_commands.create_tag(svn_path, tag_name, tag_message)
|
|
self.logger.info(f"Tag '{tag_name}' created successfully.")
|
|
self.main_frame.show_info("Success", f"Tag '{tag_name}' created.")
|
|
# Refresh list to show the new tag
|
|
self.refresh_tag_list()
|
|
except (GitCommandError, ValueError) as e:
|
|
# Handle known errors (tag exists, invalid name)
|
|
self.logger.error(f"Failed create tag '{tag_name}': {e}")
|
|
self.main_frame.show_error("Tag Error", f"Could not create tag:\n{e}")
|
|
except Exception as e:
|
|
# Handle unexpected errors during tag creation
|
|
self.logger.exception(f"Unexpected error creating tag: {e}")
|
|
self.main_frame.show_error("Error", f"Unexpected tag error:\n{e}")
|
|
else:
|
|
# User cancelled the tag input dialog
|
|
self.logger.info("Tag creation cancelled by user 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
|
|
|
|
# Get selected tag name from the listbox
|
|
selected_tag = self.main_frame.get_selected_tag() # Gets only the name
|
|
if not selected_tag:
|
|
self.logger.warning("Checkout Tag: No tag selected.")
|
|
self.main_frame.show_error("Selection Error", "Select a tag.")
|
|
return
|
|
|
|
self.logger.info(f"Attempting checkout for tag: {selected_tag}")
|
|
|
|
# CRITICAL CHECK: Ensure no uncommitted changes before checkout
|
|
try:
|
|
has_changes = self.git_commands.git_status_has_changes(svn_path)
|
|
if has_changes:
|
|
self.logger.error("Checkout blocked: Uncommitted changes exist.")
|
|
self.main_frame.show_error(
|
|
"Checkout Blocked",
|
|
"Uncommitted changes exist.\nCommit or stash first."
|
|
)
|
|
return # Prevent checkout
|
|
self.logger.debug("No uncommitted changes found.")
|
|
except (GitCommandError, ValueError) as e:
|
|
# Handle errors during status check
|
|
self.logger.error(f"Status check error before checkout: {e}")
|
|
self.main_frame.show_error("Status Error", f"Cannot check status:\n{e}")
|
|
return
|
|
except Exception as e:
|
|
# Handle unexpected errors during status check
|
|
self.logger.exception(f"Unexpected status check error: {e}")
|
|
self.main_frame.show_error("Error", f"Unexpected status error:\n{e}")
|
|
return
|
|
|
|
# CONFIRMATION dialog with warnings
|
|
confirm_msg = (
|
|
f"Checkout tag '{selected_tag}'?\n\n"
|
|
f"WARNINGS:\n"
|
|
f"- Files WILL BE OVERWRITTEN.\n"
|
|
f"- NO backup created.\n"
|
|
f"- Enters 'detached HEAD' state."
|
|
)
|
|
if not self.main_frame.ask_yes_no("Confirm Checkout", confirm_msg):
|
|
self.logger.info("Tag checkout cancelled by user.")
|
|
return
|
|
|
|
# Proceed with checkout after confirmation
|
|
self.logger.info(f"User confirmed checkout for tag: {selected_tag}")
|
|
# Save profile settings before potentially changing repo state? Optional.
|
|
if not self.save_profile_settings():
|
|
self.logger.warning("Checkout Tag: Could not save profile settings.")
|
|
# Decide whether to stop or proceed
|
|
|
|
try:
|
|
# Execute checkout command
|
|
checkout_success = self.git_commands.checkout_tag(svn_path, selected_tag)
|
|
if checkout_success:
|
|
self.logger.info(f"Tag '{selected_tag}' checked out.")
|
|
self.main_frame.show_info(
|
|
"Checkout Successful",
|
|
f"Checked out tag '{selected_tag}'.\n\n"
|
|
f"NOTE: In 'detached HEAD' state.\n"
|
|
f"Use 'git switch -' or checkout branch."
|
|
)
|
|
# TODO: Consider updating UI state to reflect detached HEAD?
|
|
# e.g., disable 'Create Tag' button?
|
|
|
|
except (GitCommandError, ValueError) as e:
|
|
# Handle known errors like tag not found
|
|
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:
|
|
# Handle unexpected errors during checkout
|
|
self.logger.exception(f"Unexpected checkout error: {e}")
|
|
self.main_frame.show_error("Error", f"Unexpected checkout error:\n{e}")
|
|
|
|
|
|
# --- GUI State Utilities ---
|
|
def _clear_and_disable_fields(self):
|
|
"""Clears relevant GUI fields and disables most buttons."""
|
|
if hasattr(self, 'main_frame'):
|
|
mf = self.main_frame # Alias
|
|
# Clear Repository 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
|
|
# Backup frame fields might retain values or load defaults
|
|
|
|
# Reset SVN indicator and dependent buttons
|
|
# This handles Prepare, EditGitignore, Commit/Tag widget states
|
|
self.update_svn_status_indicator("")
|
|
# Disable general action buttons separately
|
|
self._disable_general_buttons()
|
|
self.logger.debug("GUI fields cleared/reset. Buttons disabled.")
|
|
|
|
|
|
def _disable_general_buttons(self):
|
|
"""Disables buttons generally requiring only a loaded profile."""
|
|
if hasattr(self, 'main_frame'):
|
|
# List of general action button attribute names
|
|
button_names = [
|
|
'create_bundle_button', 'fetch_bundle_button',
|
|
'manual_backup_button', 'save_settings_button'
|
|
]
|
|
# Iterate and disable if the button exists
|
|
for name in button_names:
|
|
button = getattr(self.main_frame, name, None)
|
|
if button:
|
|
button.config(state=tk.DISABLED)
|
|
|
|
|
|
def _enable_function_buttons(self):
|
|
"""
|
|
Enables general action buttons. State-dependent buttons are handled
|
|
by update_svn_status_indicator.
|
|
"""
|
|
if hasattr(self, 'main_frame'):
|
|
general_state = tk.NORMAL
|
|
# List of general action button attribute names
|
|
button_names = [
|
|
'create_bundle_button', 'fetch_bundle_button',
|
|
'manual_backup_button', 'save_settings_button'
|
|
]
|
|
# Iterate and enable if the button exists
|
|
for name in button_names:
|
|
button = getattr(self.main_frame, name, None)
|
|
if button:
|
|
button.config(state=general_state)
|
|
|
|
# Ensure state-dependent buttons reflect the current status
|
|
# This call updates Prepare, EditGitignore, Commit/Tag widget states
|
|
current_svn_path = self.main_frame.svn_path_entry.get()
|
|
self.update_svn_status_indicator(current_svn_path)
|
|
self.logger.debug("General buttons enabled. State buttons updated.")
|
|
|
|
|
|
def show_fatal_error(self, message):
|
|
"""Shows a fatal error message before the app potentially exits."""
|
|
try:
|
|
# Determine parent window safely
|
|
parent = None
|
|
if hasattr(self, 'master') and self.master and self.master.winfo_exists():
|
|
parent = self.master
|
|
messagebox.showerror("Fatal Error", message, parent=parent)
|
|
except tk.TclError:
|
|
# Fallback if GUI is not ready or fails during error display
|
|
print(f"FATAL ERROR: {message}")
|
|
except Exception as e:
|
|
# Log error showing the message box itself
|
|
print(f"FATAL ERROR (and GUI error: {e}): {message}")
|
|
|
|
# --- Application Entry Point ---
|
|
def main():
|
|
"""Main function: Creates Tkinter root and runs the application."""
|
|
root = tk.Tk()
|
|
# Adjust min size for the new layout
|
|
root.minsize(700, 700) # Increased height needed for Commit/Tag area
|
|
app = None # Initialize app variable
|
|
try:
|
|
app = GitSvnSyncApp(root)
|
|
# Start main loop only if initialization likely succeeded
|
|
# A more robust check might involve a flag set at the end of __init__
|
|
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()
|
|
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
|
|
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:
|
|
# Log error related to showing the message box
|
|
print(f"FATAL ERROR (GUI error: {msg_e}): App failed:\n{e}")
|
|
finally:
|
|
# Log application exit regardless of success or failure
|
|
logging.info("Application exiting.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |