diff --git a/gitutility/app.py b/gitutility/app.py index 97368be..e9c8473 100644 --- a/gitutility/app.py +++ b/gitutility/app.py @@ -188,6 +188,7 @@ class GitSvnSyncApp: refresh_tags_cb=self.refresh_tag_list, create_tag_cb=self.create_tag, checkout_tag_cb=self.checkout_tag, + revert_to_tag_cb=self.revert_to_tag, # Branches Callbacks (Local) refresh_branches_cb=self.refresh_branch_list, create_branch_cb=self.create_branch, @@ -4113,6 +4114,73 @@ class GitSvnSyncApp: "status_msg": "Updating Gitea Wiki...", } ) + + def revert_to_tag(self): + """ + Handles the destructive 'Revert to Tag' action. + Confirms with user, then starts async hard reset operation. + """ + func_name: str = "revert_to_tag" + log_handler.log_info( + f"--- Action Triggered: Revert to Tag ---", func_name=func_name + ) + + # Assicurati che il frame principale esista + if not hasattr(self, "main_frame") or not self.main_frame.winfo_exists(): + return + + # Valida il percorso del repository + svn_path: Optional[str] = self._get_and_validate_svn_path("Revert to Tag") + if not svn_path or not self._is_repo_ready(svn_path): + log_handler.log_warning( + "Revert to Tag failed: Repo not ready.", func_name=func_name + ) + self.main_frame.show_error("Action Failed", "Repository is not ready.") + self.main_frame.update_status_bar("Revert failed: Repo not ready.") + return + + # Ottieni il tag selezionato dalla GUI + tag_name: Optional[str] = self.main_frame.get_selected_tag() + if not tag_name: + self.main_frame.show_error( + "Selection Error", "No tag selected from the list." + ) + self.main_frame.update_status_bar("Revert failed: No tag selected.") + return + + # --- Messaggio di Avviso Critico --- + warning_message = ( + f"WARNING: Destructive Operation\n\n" + f"You are about to reset the repository to the state of tag '{tag_name}'.\n\n" + "This operation will PERMANENTLY DELETE:\n" + " • All uncommitted changes.\n" + " • All new untracked files.\n" + " • All commits made on this branch after the tag.\n\n" + "This action cannot be undone.\n\n" + "Are you absolutely sure you want to proceed?" + ) + + if not self.main_frame.ask_yes_no("Confirm Destructive Revert", warning_message): + # L'utente ha annullato + log_handler.log_info("Revert to tag cancelled by user.", func_name=func_name) + self.main_frame.update_status_bar("Revert cancelled.") + return + + # Prepara gli argomenti e avvia l'operazione asincrona + log_handler.log_warning( + f"User confirmed destructive revert to tag '{tag_name}'. Starting worker...", + func_name=func_name + ) + args: tuple = (self.action_handler, svn_path, tag_name) + self._start_async_operation( + worker_func=async_workers.run_revert_to_tag_async, + args_tuple=args, + context_dict={ + "context": "revert_to_tag", + "status_msg": f"Reverting to tag '{tag_name}'", + "tag_name": tag_name, # Passa il tag nel contesto per il result handler + }, + ) # --- Application Entry Point --- diff --git a/gitutility/async_tasks/async_result_handler.py b/gitutility/async_tasks/async_result_handler.py index 454d7a9..ca26c7e 100644 --- a/gitutility/async_tasks/async_result_handler.py +++ b/gitutility/async_tasks/async_result_handler.py @@ -137,6 +137,7 @@ class AsyncResultHandler: "checkout_tracking_branch": self._handle_checkout_tracking_branch_result, "get_commit_details": self._handle_get_commit_details_result, "update_wiki": self._handle_generic_result, + "revert_to_tag": self._handle_revert_to_tag_result, } # Get the handler method from the map @@ -1470,6 +1471,47 @@ class AsyncResultHandler: self.main_frame.set_action_widgets_state(tk.NORMAL) self.app.master.after(delay_ms + 50, _reenable_widgets_final) + + def _handle_revert_to_tag_result( + self, result_data: Dict[str, Any], context: Dict[str, Any] + ) -> Tuple[bool, bool]: + """ + Handles the result of the 'revert to tag' operation. + Shows success/error message and triggers a full GUI refresh on success. + """ + func_name: str = "_handle_revert_to_tag_result" + status: Optional[str] = result_data.get("status") + message: Optional[str] = result_data.get("message") + tag_name_ctx: Optional[str] = context.get("tag_name") + + trigger_refreshes: bool = False + sync_refresh: bool = False + + if status == "success": + log_handler.log_info( + f"Successfully reverted repository to tag '{tag_name_ctx}'. Triggering full refresh.", + func_name=func_name, + ) + # Mostra un messaggio di informazione all'utente + if hasattr(self.main_frame, "show_info"): + self.main_frame.show_info("Operation Successful", message) + + # Segnala la necessità di un refresh completo della GUI + trigger_refreshes = True + sync_refresh = True + + elif status == "error": + log_handler.log_error( + f"Revert to tag '{tag_name_ctx}' failed: {message}", func_name=func_name + ) + # Mostra un messaggio di errore all'utente + if hasattr(self.main_frame, "show_error"): + self.main_frame.show_error("Revert to Tag Error", f"Failed:\n{message}") + + # Non è necessario chiamare _reenable_widgets_after_modal() qui, + # perché o il refresh lo farà, o il gestore generico lo farà se non ci sono refresh. + + return trigger_refreshes, sync_refresh # --- End of AsyncResultHandler Class --- diff --git a/gitutility/async_tasks/async_workers.py b/gitutility/async_tasks/async_workers.py index 83e6381..8f7e10d 100644 --- a/gitutility/async_tasks/async_workers.py +++ b/gitutility/async_tasks/async_workers.py @@ -2191,3 +2191,55 @@ def run_update_wiki_async( except Exception as qe: log_handler.log_error(f"[Worker] Failed to put result in queue for {func_name}: {qe}", func_name=func_name) log_handler.log_debug("[Worker] Finished: Update Gitea Wiki", func_name=func_name) + +def run_revert_to_tag_async( + action_handler: ActionHandler, + repo_path: str, + tag_name: str, + results_queue: queue.Queue[Dict[str, Any]], +) -> None: + """Worker to perform a hard reset to a tag asynchronously.""" + func_name: str = "run_revert_to_tag_async" + log_handler.log_debug( + f"[Worker] Started: Revert to Tag '{tag_name}' in '{repo_path}'", + func_name=func_name, + ) + result_payload: Dict[str, Any] = { + "status": "error", + "result": False, + "message": f"Failed to revert to tag '{tag_name}'.", + "exception": None, + } + try: + # Chiama il metodo dell'ActionHandler + success: bool = action_handler.execute_revert_to_tag(repo_path, tag_name) + + # Prepara il risultato di successo + result_payload["status"] = "success" + result_payload["result"] = success + result_payload["message"] = f"Repository successfully reverted to tag '{tag_name}'." + log_handler.log_info(f"[Worker] {result_payload['message']}", func_name=func_name) + + except (GitCommandError, ValueError, Exception) as e: + # Cattura qualsiasi eccezione dall'ActionHandler + log_handler.log_exception( + f"[Worker] EXCEPTION reverting to tag: {e}", func_name=func_name + ) + result_payload["exception"] = e + result_payload["message"] = f"Error reverting to tag '{tag_name}': {e}" + # Se l'errore è specifico, potremmo volerlo mostrare in modo diverso + if "not found" in str(e).lower(): + result_payload["message"] = f"Error: Tag '{tag_name}' not found." + + finally: + # Metti sempre il risultato nella coda, sia in caso di successo che di errore + try: + results_queue.put(result_payload) + except Exception as qe: + log_handler.log_error( + f"[Worker] Failed to put result in queue for {func_name}: {qe}", + func_name=func_name, + ) + log_handler.log_debug( + f"[Worker] Finished: Revert to Tag '{tag_name}'", func_name=func_name + ) diff --git a/gitutility/commands/git_commands.py b/gitutility/commands/git_commands.py index 896c9e8..bd54a92 100644 --- a/gitutility/commands/git_commands.py +++ b/gitutility/commands/git_commands.py @@ -1982,6 +1982,62 @@ class GitCommands: f"Unexpected error running git diff-tree: {e}", func_name=func_name ) return -1, [] + + def git_reset_hard(self, working_directory: str, reference: str) -> None: + """ + Performs a hard reset to a given reference and cleans the working directory. + WARNING: This is a destructive operation. + + Args: + working_directory (str): The path to the Git repository. + reference (str): The tag, branch, or commit hash to reset to. + + Raises: + ValueError: If the reference is empty. + GitCommandError: If any of the Git commands fail. + """ + func_name: str = "git_reset_hard" + if not reference or reference.isspace(): + raise ValueError("Reference for reset cannot be empty.") + + log_handler.log_warning( + f"Performing DESTRUCTIVE operation: git reset --hard {reference}", + func_name=func_name, + ) + + # 1. Esegui git reset --hard + reset_cmd: List[str] = ["git", "reset", "--hard", reference] + try: + self.log_and_execute( + reset_cmd, working_directory, check=True, log_output_level=logging.INFO + ) + log_handler.log_info( + f"Hard reset to '{reference}' completed successfully.", func_name=func_name + ) + except GitCommandError as e: + log_handler.log_error( + f"git reset --hard to '{reference}' FAILED.", func_name=func_name + ) + # Rilancia l'eccezione per fermare l'operazione + raise e + + log_handler.log_warning( + "Performing DESTRUCTIVE operation: git clean -fdx", func_name=func_name + ) + + # 2. Esegui git clean -fdx per rimuovere file non tracciati + clean_cmd: List[str] = ["git", "clean", "-fdx"] + try: + self.log_and_execute( + clean_cmd, working_directory, check=True, log_output_level=logging.INFO + ) + log_handler.log_info( + "Clean of untracked files completed successfully.", func_name=func_name + ) + except GitCommandError as e: + log_handler.log_error("git clean -fdx FAILED.", func_name=func_name) + # Rilancia l'eccezione + raise e # --- END OF FILE gitsync_tool/commands/git_commands.py --- diff --git a/gitutility/core/action_handler.py b/gitutility/core/action_handler.py index ac6f291..54a0810 100644 --- a/gitutility/core/action_handler.py +++ b/gitutility/core/action_handler.py @@ -1639,6 +1639,61 @@ class ActionHandler: } return result_info + + def execute_revert_to_tag(self, repo_path: str, tag_name: str) -> bool: + """ + Executes a hard reset to the specified tag and cleans the working directory. + This is a wrapper for a destructive operation. + + Args: + repo_path (str): The path to the repository. + tag_name (str): The tag to revert to. + + Returns: + bool: True if the operation was successful. + + Raises: + ValueError: If inputs are invalid. + GitCommandError: If the underlying git commands fail. + """ + func_name: str = "execute_revert_to_tag" + log_handler.log_warning( + f"Executing destructive revert to tag '{tag_name}' in repo: {repo_path}", + func_name=func_name, + ) + + # --- Validazione Input --- + if not repo_path or not os.path.isdir(repo_path): + raise ValueError(f"Invalid repository path provided: '{repo_path}'") + if not tag_name or tag_name.isspace(): + raise ValueError("Tag name for revert cannot be empty.") + if not os.path.exists(os.path.join(repo_path, ".git")): + raise ValueError(f"Directory '{repo_path}' is not a valid Git repository.") + + try: + # Chiama il metodo di basso livello in GitCommands + self.git_commands.git_reset_hard(repo_path, tag_name) + + log_handler.log_info( + f"Successfully reverted repository to tag '{tag_name}'.", + func_name=func_name, + ) + return True # L'operazione è andata a buon fine + + except (GitCommandError, ValueError) as e: + # Logga e rilancia l'eccezione per essere gestita dal worker + log_handler.log_error( + f"Failed to revert to tag '{tag_name}': {e}", func_name=func_name + ) + raise e + except Exception as e: + # Cattura altri errori imprevisti + log_handler.log_exception( + f"An unexpected error occurred during revert to tag '{tag_name}': {e}", + func_name=func_name, + ) + # Rilancia come un'eccezione generica o GitCommandError per coerenza + raise Exception(f"Unexpected revert error: {e}") from e # --- END OF FILE gitsync_tool/core/action_handler.py --- diff --git a/gitutility/gui/main_frame.py b/gitutility/gui/main_frame.py index 4479e5c..25b3b3c 100644 --- a/gitutility/gui/main_frame.py +++ b/gitutility/gui/main_frame.py @@ -74,6 +74,7 @@ class MainFrame(ttk.Frame): refresh_tags_cb: Callable[[], None], create_tag_cb: Callable[[], None], checkout_tag_cb: Callable[[], None], + revert_to_tag_cb: Callable[[], None], refresh_history_cb: Callable[[], None], refresh_branches_cb: Callable[[], None], # Callback unico per refresh locali checkout_branch_cb: Callable[ @@ -123,6 +124,7 @@ class MainFrame(ttk.Frame): self.refresh_tags_callback = refresh_tags_cb self.create_tag_callback = create_tag_cb self.checkout_tag_callback = checkout_tag_cb + self.revert_to_tag_callback = revert_to_tag_cb self.refresh_history_callback = refresh_history_cb self.refresh_branches_callback = refresh_branches_cb self.checkout_branch_callback = checkout_branch_cb @@ -756,6 +758,20 @@ class MainFrame(ttk.Frame): self.checkout_tag_button, "Switch the working directory to the state of the selected tag (Detached HEAD).", ) + + self.revert_to_tag_button = ttk.Button( + button_frame, + text="Revert to this Tag", + width=bw, + command=self.revert_to_tag_callback, # Nuovo callback + state=tk.DISABLED, + ) + self.revert_to_tag_button.pack(side=tk.TOP, fill=tk.X, pady=5) + self.create_tooltip( + self.revert_to_tag_button, + "DESTRUCTIVE: Resets the current branch to this tag.\nAll later commits and uncommitted changes will be lost.", + ) + return frame def _create_branch_tab(self): @@ -1857,6 +1873,7 @@ class MainFrame(ttk.Frame): "refresh_tags_button", "create_tag_button", "checkout_tag_button", + "revert_to_tag_button", "refresh_branches_button", "create_branch_button", "checkout_branch_button",