From ad4fe4cf4421af6ac25332d0c81a5a80231d3938 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Wed, 24 Sep 2025 15:39:00 +0200 Subject: [PATCH] add reset to commit from history tab --- gitutility/app.py | 3 + .../async_tasks/async_result_handler.py | 33 +++++++++- gitutility/async_tasks/async_workers.py | 54 +++++++++++++--- gitutility/commands/git_commands.py | 32 +++++++++- gitutility/core/wiki_updater.py | 60 +++++++++++------- gitutility/gui/tabs/history_tab.py | 63 ++++++++++++++++++- gitutility/logic/repository_handler.py | 15 +++++ 7 files changed, 223 insertions(+), 37 deletions(-) diff --git a/gitutility/app.py b/gitutility/app.py index 0f6d1db..ea82680 100644 --- a/gitutility/app.py +++ b/gitutility/app.py @@ -137,6 +137,7 @@ class GitSvnSyncApp: merge_local_branch_cb=self.merge_local_branch, compare_branch_with_current_cb=self.compare_branch_with_current, view_commit_details_cb=self.view_commit_details, + reset_to_commit_cb=self.repository_handler.handle_reset_to_commit if self.repository_handler else None, config_manager_instance=self.config_manager, profile_sections_list=self.config_manager.get_profile_sections(), ) @@ -178,6 +179,8 @@ class GitSvnSyncApp: self.main_frame.tags_tab.create_tag_callback = self.repository_handler.create_tag self.main_frame.tags_tab.checkout_tag_callback = self.repository_handler.checkout_tag self.main_frame.tags_tab.revert_to_tag_callback = self.repository_handler.revert_to_tag + + self.main_frame.history_tab.reset_to_commit_callback = self.repository_handler.handle_reset_to_commit self.main_frame.branch_tab.create_branch_callback = self.repository_handler.create_branch self.main_frame.branch_tab.checkout_branch_callback = self.repository_handler.checkout_branch diff --git a/gitutility/async_tasks/async_result_handler.py b/gitutility/async_tasks/async_result_handler.py index bbec754..a4c645a 100644 --- a/gitutility/async_tasks/async_result_handler.py +++ b/gitutility/async_tasks/async_result_handler.py @@ -20,7 +20,7 @@ import re from gitutility.logging_setup import log_handler from gitutility.commands.git_commands import GitCommandError from gitutility.async_tasks import async_workers -from gitutility.config.config_manager import DEFAULT_REMOTE_NAME +from ..core.wiki_updater import WikiUpdateStatus if TYPE_CHECKING: from ..app import GitSvnSyncApp @@ -80,7 +80,7 @@ class AsyncResultHandler: "clone_remote": self._handle_clone_remote_result, "checkout_tracking_branch": self._handle_checkout_tracking_branch_result, "get_commit_details": self._handle_get_commit_details_result, - "update_wiki": self._handle_generic_result, + "update_wiki": self._handle_update_wiki_result, "revert_to_tag": self._handle_revert_to_tag_result, "analyze_history": self._handle_analyze_history_result, "purge_history": self._handle_purge_history_result, @@ -92,6 +92,7 @@ class AsyncResultHandler: "init_submodules": self._handle_init_submodules_result, "force_clean_submodule": self._handle_force_clean_submodule_result, "clean_submodule": self._handle_clean_submodule_result, + "reset_to_commit": self._handle_reset_to_commit_result, } handler_method = handler_map.get(task_context) @@ -107,6 +108,14 @@ class AsyncResultHandler: if should_trigger_refreshes: self.app._trigger_post_action_refreshes(post_action_sync_refresh_needed) + def _handle_reset_to_commit_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: + if result_data.get("status") == "success": + self.main_frame.show_info("Operation Successful", result_data.get("message")) + return True, True + else: + self.main_frame.show_error("Reset to Commit Error", f"Failed:\n{result_data.get('message')}") + return False, False + def _handle_refresh_tags_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: if result_data.get("status") == "success": self.main_frame.tags_tab.update_tag_list(result_data.get("result", [])) @@ -599,4 +608,24 @@ class AsyncResultHandler: statuses = result_data.get("result", {}) self.main_frame.submodules_tab.update_remote_statuses(statuses) + return False, False + + def _handle_update_wiki_result(self, result_data: Dict[str, Any], context: Dict[str, Any]) -> Tuple[bool, bool]: + """Handles the result of the update_wiki_from_docs async task.""" + status = result_data.get("status") + message = result_data.get("message", "Wiki operation finished.") + + if status == "success": + # In our new implementation, 'success' is for both actual updates and no-changes scenarios + self.main_frame.show_info("Wiki Update", message) + # We only need to trigger a full refresh if something actually changed. + # The result object from the worker now contains the detailed status. + update_result = result_data.get("result") + if update_result and update_result.status == WikiUpdateStatus.SUCCESS: + return True, True + elif status == "warning": + self.main_frame.show_warning("Wiki Update Info", message) + elif status == "error": + self.main_frame.show_error("Wiki Update Error", f"Failed:\n{message}") + return False, False \ No newline at end of file diff --git a/gitutility/async_tasks/async_workers.py b/gitutility/async_tasks/async_workers.py index 1d4353c..fbeb48e 100644 --- a/gitutility/async_tasks/async_workers.py +++ b/gitutility/async_tasks/async_workers.py @@ -13,7 +13,7 @@ from ..commands.git_commands import GitCommands, GitCommandError from ..core.action_handler import ActionHandler from ..core.backup_handler import BackupHandler from ..core.remote_actions import RemoteActionHandler -from ..core.wiki_updater import WikiUpdater +from ..core.wiki_updater import WikiUpdater, WikiUpdateStatus from ..core.history_cleaner import HistoryCleaner from ..core.submodule_handler import SubmoduleHandler @@ -1040,17 +1040,27 @@ def run_update_wiki_async( "status": "error", "message": "Wiki update failed.", "exception": None, - "result": False, + "result": None, } try: - success, message = wiki_updater.update_wiki_from_docs( + update_result = wiki_updater.update_wiki_from_docs( main_repo_path=main_repo_path, main_repo_remote_url=main_repo_remote_url, ) - result_payload["status"] = "success" if success else "error" - result_payload["message"] = message - result_payload["result"] = success - log_handler.log_info(f"[Worker] Wiki update result: {message}", func_name=func_name) + + # The status for the queue can be 'success', 'warning', or 'error' + # We map our detailed status to one of these + if update_result.status in [WikiUpdateStatus.SUCCESS, WikiUpdateStatus.NO_CHANGES]: + queue_status = "success" + elif update_result.status in [WikiUpdateStatus.DOC_NOT_FOUND]: + queue_status = "warning" + else: + queue_status = "error" + + result_payload["status"] = queue_status + result_payload["message"] = update_result.message + result_payload["result"] = update_result # Pass the whole result object + log_handler.log_info(f"[Worker] Wiki update result: {update_result.message}", func_name=func_name) except Exception as e: log_handler.log_exception(f"[Worker] UNEXPECTED EXCEPTION during wiki update: {e}", func_name=func_name) @@ -1092,6 +1102,36 @@ def run_revert_to_tag_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(f"[Worker] Finished: Revert to Tag '{tag_name}'", func_name=func_name) + +def run_reset_to_commit_async( + action_handler: ActionHandler, + repo_path: str, + commit_hash: str, + results_queue: queue.Queue[Dict[str, Any]], +) -> None: + """Worker to perform a hard reset to a specific commit asynchronously.""" + func_name = "run_reset_to_commit_async" + log_handler.log_debug(f"[Worker] Started: Reset to Commit '{commit_hash}' in '{repo_path}'", func_name=func_name) + result_payload: Dict[str, Any] = { + "status": "error", "result": False, "message": f"Failed to reset to commit '{commit_hash[:7]}'.", "exception": None, + } + try: + # Directly call the git_reset_hard from action_handler's git_commands + action_handler.git_commands.git_reset_hard(repo_path, commit_hash) + result_payload["status"] = "success" + result_payload["result"] = True + result_payload["message"] = f"Repository successfully reset to commit '{commit_hash[:7]}'." + log_handler.log_info(f"[Worker] {result_payload['message']}", func_name=func_name) + except (GitCommandError, ValueError, Exception) as e: + log_handler.log_exception(f"[Worker] EXCEPTION resetting to commit: {e}", func_name=func_name) + result_payload["exception"] = e + result_payload["message"] = f"Error resetting to commit '{commit_hash[:7]}': {e}" + finally: + 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: Reset to Commit '{commit_hash}'", func_name=func_name) def run_analyze_repo_for_purge_async( history_cleaner: HistoryCleaner, diff --git a/gitutility/commands/git_commands.py b/gitutility/commands/git_commands.py index 2253c32..d596cac 100644 --- a/gitutility/commands/git_commands.py +++ b/gitutility/commands/git_commands.py @@ -905,6 +905,32 @@ class GitCommands: raise e # --- History / Log --- + def reset_hard(self, working_directory: str, commit_hash: str) -> None: + """ + Performs a git reset --hard to the specified commit hash. + + Args: + working_directory (str): Path to the repository. + commit_hash (str): The commit hash to reset to. + """ + func_name = "reset_hard" + log_handler.log_warning( + f"Performing a destructive 'git reset --hard' to '{commit_hash}' in '{working_directory}'", + func_name=func_name + ) + if not commit_hash or commit_hash.isspace(): + raise ValueError("Commit hash cannot be empty.") + + cmd = ["git", "reset", "--hard", commit_hash] + try: + self.log_and_execute(cmd, working_directory, check=True) + log_handler.log_info( + f"Successfully reset to commit '{commit_hash}'.", func_name=func_name + ) + except GitCommandError as e: + log_handler.log_error(f"Failed to 'git reset --hard': {e}", func_name=func_name) + raise + def get_commit_log( self, working_directory: str, max_count: int = 200, branch: Optional[str] = None ) -> List[str]: @@ -2048,11 +2074,11 @@ class GitCommands: raise e log_handler.log_warning( - "Performing DESTRUCTIVE operation: git clean -fdx", func_name=func_name + "Performing DESTRUCTIVE operation: git clean -fd", func_name=func_name ) - # 2. Esegui git clean -fdx per rimuovere file non tracciati - clean_cmd: List[str] = ["git", "clean", "-fdx"] + # 2. Esegui git clean -fd per rimuovere file non tracciati + clean_cmd: List[str] = ["git", "clean", "-fd"] try: self.log_and_execute( clean_cmd, working_directory, check=True, log_output_level=logging.INFO diff --git a/gitutility/core/wiki_updater.py b/gitutility/core/wiki_updater.py index 597d109..2ba0f84 100644 --- a/gitutility/core/wiki_updater.py +++ b/gitutility/core/wiki_updater.py @@ -5,14 +5,33 @@ import shutil import tempfile import re import time -from typing import Optional, Tuple +from typing import Optional from urllib.parse import urlparse, urlunparse +from enum import Enum, auto +from dataclasses import dataclass from ..commands.git_commands import GitCommands, GitCommandError from ..logging_setup import log_handler +class WikiUpdateStatus(Enum): + """Represents the result of the wiki update operation.""" + SUCCESS = auto() + NO_CHANGES = auto() + URL_DERIVATION_FAILED = auto() + CLONE_FAILED = auto() + PUSH_FAILED = auto() + COMMIT_FAILED = auto() + DOC_NOT_FOUND = auto() + BRANCH_NOT_FOUND = auto() + GENERIC_ERROR = auto() + +@dataclass +class WikiUpdateResult: + """Holds the result of the wiki update operation.""" + status: WikiUpdateStatus + message: str + class WikiUpdater: - # ... (il resto della classe è invariato fino a update_wiki_from_docs) DEFAULT_WIKI_EN_FILENAME = "English-Manual.md" DEFAULT_WIKI_IT_FILENAME = "Italian-Manual.md" @@ -23,7 +42,6 @@ class WikiUpdater: log_handler.log_debug("WikiUpdater initialized.", func_name="__init__") def _get_wiki_repo_url(self, main_repo_url: str) -> Optional[str]: - # ... (codice invariato) if not main_repo_url: return None try: @@ -52,7 +70,7 @@ class WikiUpdater: wiki_en_target_filename: Optional[str] = None, wiki_it_target_filename: Optional[str] = None, commit_message: str = "Update Wiki documentation from local files" - ) -> Tuple[bool, str]: + ) -> WikiUpdateResult: """ Clones the Gitea wiki repo, updates pages from local doc files, and pushes. """ @@ -63,7 +81,7 @@ class WikiUpdater: if not wiki_url: msg = "Could not derive Wiki repository URL from main remote URL." log_handler.log_error(msg, func_name=func_name) - return False, msg + return WikiUpdateResult(status=WikiUpdateStatus.URL_DERIVATION_FAILED, message=msg) log_handler.log_debug(f"Derived wiki URL: {wiki_url}", func_name=func_name) @@ -78,7 +96,7 @@ class WikiUpdater: if not en_exists and not it_exists: msg = f"Neither '{en_manual_filename}' nor '{it_manual_filename}' found in '{doc_path}'. Cannot update Wiki." log_handler.log_warning(msg, func_name=func_name) - return False, msg + return WikiUpdateResult(status=WikiUpdateStatus.DOC_NOT_FOUND, message=msg) temp_dir: Optional[str] = None try: @@ -93,7 +111,7 @@ class WikiUpdater: else: msg = f"Failed to clone Wiki repository '{wiki_url}'. Error: {stderr_msg.strip()}" log_handler.log_error(msg, func_name=func_name) - return False, f"{msg} (Check authentication?)" + return WikiUpdateResult(status=WikiUpdateStatus.CLONE_FAILED, message=f"{msg} (Check authentication?)") log_handler.log_info("Wiki repository cloned successfully.", func_name=func_name) @@ -108,26 +126,24 @@ class WikiUpdater: if not self.git_commands.git_status_has_changes(temp_dir): msg = "Wiki content is already up-to-date with local doc files." log_handler.log_info(msg, func_name=func_name) - return True, msg + return WikiUpdateResult(status=WikiUpdateStatus.NO_CHANGES, message=msg) - # --- MODIFICA PER PROBLEMA COMMIT --- log_handler.log_info("Changes detected. Staging with --renormalize to handle line endings.", func_name=func_name) - # Questo comando forza Git a ri-processare i file, risolvendo problemi di CRLF/LF self.git_commands.add_file(temp_dir, ".", renormalize=True) - # Ora eseguiamo il commit, ma senza fare un altro 'add' + commit_success = self.git_commands.git_commit(temp_dir, commit_message, stage_all_first=False) - # --- FINE MODIFICA --- - if not commit_success: msg = "Staged changes, but commit reported no changes to commit. Push will be skipped." log_handler.log_warning(msg, func_name=func_name) - return True, msg # Consideriamo un "successo" perché non c'era nulla di sostanziale da pushare + return WikiUpdateResult(status=WikiUpdateStatus.NO_CHANGES, message=msg) log_handler.log_info("Wiki changes committed locally.", func_name=func_name) current_wiki_branch = self.git_commands.get_current_branch_name(temp_dir) if not current_wiki_branch: - return False, "Could not determine the current branch in the cloned wiki repository." + msg = "Could not determine the current branch in the cloned wiki repository." + log_handler.log_error(msg, func_name=func_name) + return WikiUpdateResult(status=WikiUpdateStatus.BRANCH_NOT_FOUND, message=msg) log_handler.log_info("Pushing wiki changes to remote...", func_name=func_name) push_result = self.git_commands.git_push( @@ -140,30 +156,28 @@ class WikiUpdater: if push_result.returncode == 0: msg = "Wiki update pushed successfully to Gitea." log_handler.log_info(msg, func_name=func_name) - return True, msg + return WikiUpdateResult(status=WikiUpdateStatus.SUCCESS, message=msg) else: stderr_msg = push_result.stderr or "Unknown push error" msg = f"Failed to push Wiki updates. Error: {stderr_msg.strip()}" log_handler.log_error(msg, func_name=func_name) - return False, msg + return WikiUpdateResult(status=WikiUpdateStatus.PUSH_FAILED, message=msg) except (GitCommandError, ValueError, IOError, Exception) as e: log_handler.log_exception(f"Error during wiki update process: {e}", func_name=func_name) - return False, f"Wiki update failed: {e}" + return WikiUpdateResult(status=WikiUpdateStatus.GENERIC_ERROR, message=f"Wiki update failed: {e}") finally: - # --- MODIFICA PER PROBLEMA PULIZIA --- if temp_dir and os.path.isdir(temp_dir): log_handler.log_debug(f"Attempting to clean up temporary directory: {temp_dir}", func_name=func_name) - for attempt in range(3): # Prova fino a 3 volte + for attempt in range(3): try: shutil.rmtree(temp_dir) log_handler.log_info(f"Cleanup of temporary directory successful.", func_name=func_name) - break # Esci dal ciclo se la pulizia ha successo + break except Exception as clean_e: log_handler.log_warning(f"Cleanup attempt {attempt + 1} failed for '{temp_dir}': {clean_e}", func_name=func_name) if attempt < 2: - time.sleep(0.5) # Aspetta 500ms prima di riprovare + time.sleep(0.5) else: log_handler.log_error(f"Final cleanup attempt failed for '{temp_dir}'.", func_name=func_name) - # --- FINE MODIFICA --- \ No newline at end of file diff --git a/gitutility/gui/tabs/history_tab.py b/gitutility/gui/tabs/history_tab.py index 68766be..2be0a9f 100644 --- a/gitutility/gui/tabs/history_tab.py +++ b/gitutility/gui/tabs/history_tab.py @@ -27,6 +27,7 @@ class HistoryTab(ttk.Frame): # Store callbacks self.refresh_history_callback = kwargs.get('refresh_history_cb') self.view_commit_details_callback = kwargs.get('view_commit_details_cb') + self.reset_to_commit_callback = kwargs.get('reset_to_commit_cb') # --- Get a reference to the main frame for shared components --- self.main_frame = self.master.master @@ -97,7 +98,8 @@ class HistoryTab(ttk.Frame): tree_scrollbar_x.grid(row=1, column=0, columnspan=2, sticky="ew") self.history_tree.bind("", self._on_history_double_click) - Tooltip(self.history_tree, "Double-click a commit line to view details.") + self.history_tree.bind("", self._show_context_menu) + Tooltip(self.history_tree, "Double-click a commit line to view details.\nRight-click for more options.") def set_action_widgets_state(self, state: str) -> None: """Sets the state of all action widgets in this tab.""" @@ -115,8 +117,10 @@ class HistoryTab(ttk.Frame): if self.history_tree and self.history_tree.winfo_exists(): if state == tk.DISABLED: self.history_tree.unbind("") + self.history_tree.unbind("") else: self.history_tree.bind("", self._on_history_double_click) + self.history_tree.bind("", self._show_context_menu) def update_history_display(self, log_lines: List[str]) -> None: """Populates the history treeview with parsed log data.""" @@ -185,4 +189,59 @@ class HistoryTab(ttk.Frame): self.view_commit_details_callback(commit_hash) except Exception as e: log_handler.log_exception(f"Error handling history double-click: {e}", func_name=func_name) - messagebox.showerror("Error", f"Could not process history selection:\n{e}", parent=self) \ No newline at end of file + messagebox.showerror("Error", f"Could not process history selection:\n{e}", parent=self) + + def _show_context_menu(self, event: tk.Event) -> None: + """Displays a context menu on right-click.""" + # Select the item under the cursor + iid = self.history_tree.identify_row(event.y) + if not iid: # Clicked outside of any item + return + + self.history_tree.selection_set(iid) + self.history_tree.focus(iid) + + item_data = self.history_tree.item(iid) + item_values = item_data.get("values") + if not item_values or not str(item_values[0]).strip(): + return + + commit_hash = str(item_values[0]).strip() + + context_menu = tk.Menu(self, tearoff=0) + context_menu.add_command( + label=f"View Details for {commit_hash[:7]}...", + command=lambda: self.view_commit_details_callback(commit_hash) + ) + context_menu.add_separator() + context_menu.add_command( + label=f"Reset branch to this commit ({commit_hash[:7]}) ...", + command=lambda: self._on_reset_to_commit(commit_hash) + ) + + try: + context_menu.tk_popup(event.x_root, event.y_root) + finally: + context_menu.grab_release() + + def _on_reset_to_commit(self, commit_hash: str) -> None: + """Handles the 'Reset to commit' action from the context menu.""" + func_name = "_on_reset_to_commit" + if not callable(self.reset_to_commit_callback): + log_handler.log_warning("reset_to_commit_callback is not available.", func_name) + return + + title = "Confirm Destructive Action" + message = ( + f"Are you sure you want to reset your current branch to commit {commit_hash[:7]}?" + f"WARNING: This is a destructive operation!" + f"- All commits made after this one will be permanently lost." + f"- All uncommitted changes in your working directory will be permanently lost." + f"This action cannot be undone. Proceed with caution." + ) + + if messagebox.askokcancel(title, message, icon=messagebox.WARNING, parent=self): + log_handler.log_warning(f"User confirmed reset to commit: '{commit_hash}'", func_name) + self.reset_to_commit_callback(commit_hash) + else: + log_handler.log_info("User cancelled reset operation.", func_name) \ No newline at end of file diff --git a/gitutility/logic/repository_handler.py b/gitutility/logic/repository_handler.py index 955ba0c..ec91251 100644 --- a/gitutility/logic/repository_handler.py +++ b/gitutility/logic/repository_handler.py @@ -407,4 +407,19 @@ class RepositoryHandler: worker_func=async_workers.run_get_commit_details_async, args_tuple=args, context_dict={"context": "get_commit_details", "status_msg": f"Loading details for commit {commit_hash_short}"} + ) + + def handle_reset_to_commit(self, commit_hash: str): + """Handles the destructive 'Reset to Commit' action.""" + func_name = "handle_reset_to_commit" + svn_path = self.app._get_and_validate_svn_path("Reset to Commit") + if not svn_path or not self.app._is_repo_ready(svn_path): + self.main_frame.show_error("Action Failed", "Repository is not ready.") + return + + args = (self.action_handler, svn_path, commit_hash) + self.app._start_async_operation( + worker_func=async_workers.run_reset_to_commit_async, + args_tuple=args, + context_dict={"context": "reset_to_commit", "status_msg": f"Resetting to commit '{commit_hash[:7]}'"}, ) \ No newline at end of file