add purge function

This commit is contained in:
VALLONGOL 2025-07-07 15:05:51 +02:00
parent c84348b263
commit 32379dcc50
8 changed files with 1566 additions and 2243 deletions

View File

@ -1,46 +1,6 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
import os
a = Analysis(scripts=['gitutility\\__main__.py'],
pathex=['gitutility'],
binaries=[],
datas=[('C:\\src\\____GitProjects\\GitUtility\\git_svn_sync.ini', '.')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
noarchive=False)
a = Analysis(scripts=['gitutility\\__main__.py'], pathex=['gitutility', '.'], binaries=[], datas=[('C:\\src\\____GitProjects\\GitUtility\\git_svn_sync.ini', '.')], hiddenimports=[], hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=None, noarchive=False)
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
exe = EXE(pyz,
a.scripts,
[], # Binaries/Datas usually handled by Analysis/COLLECT
exclude_binaries=True, # Let COLLECT handle binaries in one-dir
name='GitUtility',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True, # Use UPX based on config
runtime_tmpdir=None,
console=False, # Set console based on GUI checkbox
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='GitUtility.ico')
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True, # Match UPX setting
upx_exclude=[],
name='GitUtility')
exe = EXE(pyz, a.scripts, [], exclude_binaries=True, name='GitUtility', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, runtime_tmpdir=None, console=False, disable_windowed_traceback=False, target_arch=None, codesign_identity=None, entitlements_file=None, icon='GitUtility.ico')
coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='GitUtility')

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,6 @@ from typing import (
Set,
) # Aggiunto Set
# ---<<< MODIFICA IMPORT >>>---
# Usa percorsi assoluti per importare moduli dal pacchetto
from gitutility.logging_setup import log_handler
from gitutility.commands.git_commands import GitCommandError
@ -33,7 +32,6 @@ if TYPE_CHECKING:
# Importa MainFrame dal nuovo percorso
from gitutility.gui.main_frame import MainFrame
# ---<<< FINE MODIFICA IMPORT >>>---
class AsyncResultHandler:
@ -138,6 +136,8 @@ class AsyncResultHandler:
"get_commit_details": self._handle_get_commit_details_result,
"update_wiki": self._handle_generic_result,
"revert_to_tag": self._handle_revert_to_tag_result,
"analyze_history": self._handle_analyze_history_result,
"purge_history": self._handle_purge_history_result,
}
# Get the handler method from the map
@ -1513,5 +1513,84 @@ class AsyncResultHandler:
return trigger_refreshes, sync_refresh
def _handle_analyze_history_result(
self, result_data: Dict[str, Any], context: Dict[str, Any]
) -> Tuple[bool, bool]:
"""
Handles the result of the history analysis. Opens a confirmation dialog if
purgeable files are found.
"""
func_name = "_handle_analyze_history_result"
status = result_data.get("status")
message = result_data.get("message")
purgeable_files = result_data.get("result", [])
repo_path = context.get("repo_path")
if status == "success":
if purgeable_files:
log_handler.log_info(
"Analysis successful. Found files to purge. Showing confirmation dialog.",
func_name=func_name
)
# Chiamata al metodo di app.py per mostrare il dialogo
# Questo metodo gestirà il flusso successivo (conferma/annulla)
# e riabiliterà i widget se necessario.
self.app.show_purge_confirmation_and_purge(repo_path, purgeable_files)
else:
log_handler.log_info(
"Analysis successful. No purgeable files found.",
func_name=func_name
)
self.main_frame.show_info("Analysis Complete", message)
# Riabilita i widget perché non c'è nessuna azione successiva
self._reenable_widgets_after_modal()
elif status == "error":
log_handler.log_error(
f"History analysis failed: {message}",
func_name=func_name
)
self.main_frame.show_error("Analysis Error", f"Failed to analyze repository history:\n{message}")
# Riabilita i widget perché l'operazione è fallita
self._reenable_widgets_after_modal()
# Questo handler non innesca un refresh automatico; il flusso è gestito dal dialogo.
return False, False
def _handle_purge_history_result(
self, result_data: Dict[str, Any], context: Dict[str, Any]
) -> Tuple[bool, bool]:
"""
Handles the result of the history purge operation. Shows a message and
triggers a full GUI refresh on success.
"""
func_name = "_handle_purge_history_result"
status = result_data.get("status")
message = result_data.get("message")
trigger_refreshes = False
sync_refresh = False
if status == "success":
log_handler.log_info(
f"History purge successful. Message: {message}",
func_name=func_name
)
self.main_frame.show_info("Purge Successful", message)
# È FONDAMENTALE fare un refresh completo dopo la riscrittura della storia
trigger_refreshes = True
sync_refresh = True
elif status == "error":
log_handler.log_error(
f"History purge failed: {message}",
func_name=func_name
)
self.main_frame.show_error("Purge Failed", f"The history cleaning process failed:\n\n{message}")
# Riabilita i widget dato che l'operazione è fallita
self._reenable_widgets_after_modal()
return trigger_refreshes, sync_refresh
# --- End of AsyncResultHandler Class ---

File diff suppressed because it is too large Load Diff

View File

@ -2039,5 +2039,144 @@ class GitCommands:
# Rilancia l'eccezione
raise e
# --- NUOVI METODI PER HISTORY CLEANER ---
# --- END OF FILE gitsync_tool/commands/git_commands.py ---
def list_all_historical_blobs(self, working_directory: str) -> List[Tuple[str, str]]:
"""
Lists all blobs (file contents) ever recorded in the repository history,
along with their original file paths.
Returns:
List[Tuple[str, str]]: A list of (blob_hash, file_path) tuples.
"""
func_name = "list_all_historical_blobs"
log_handler.log_debug(f"Listing all historical blobs in '{working_directory}'...", func_name=func_name)
# This command lists all objects reachable from any ref.
# It's a comprehensive way to find all files that ever existed.
cmd = ["git", "rev-list", "--all", "--objects"]
try:
result = self.log_and_execute(cmd, working_directory, check=True, log_output_level=logging.DEBUG)
blobs = []
lines = result.stdout.strip().splitlines()
for line in lines:
parts = line.split(maxsplit=1)
if len(parts) == 2:
blob_hash, file_path = parts
# We are only interested in blobs (files), not trees or commits.
# A quick heuristic is to check if it has a file extension or doesn't look like a hash.
# A more robust check could use `git cat-file -t <hash>`, but that would be slow.
# We'll rely on `git-filter-repo` to handle non-blob paths gracefully if any slip through.
blobs.append((blob_hash, file_path))
log_handler.log_info(f"Found {len(blobs)} total object entries. These will be filtered.", func_name=func_name)
return blobs
except GitCommandError as e:
log_handler.log_error(f"Failed to list historical objects: {e}", func_name=func_name)
raise
def get_blob_size(self, working_directory: str, blob_hash: str) -> int:
"""
Gets the size of a git blob in bytes.
Args:
blob_hash (str): The SHA-1 hash of the blob.
Returns:
int: The size of the blob in bytes.
"""
cmd = ["git", "cat-file", "-s", blob_hash]
try:
result = self.log_and_execute(cmd, working_directory, check=True, log_output_level=logging.DEBUG)
return int(result.stdout.strip())
except (GitCommandError, ValueError) as e:
raise GitCommandError(f"Failed to get size for blob {blob_hash}: {e}", command=cmd) from e
def run_filter_repo(self, working_directory: str, paths_file: str) -> None:
"""
Executes 'git-filter-repo' to remove files specified in a file.
Args:
working_directory (str): Path to the repository.
paths_file (str): Path to a file containing one file path per line to remove.
"""
func_name = "run_filter_repo"
log_handler.log_warning(f"--- Running DESTRUCTIVE git-filter-repo in '{working_directory}' ---", func_name=func_name)
# Command to remove specific paths from history.
# --invert-paths means it will process the paths listed in the file.
# --force is needed because we are not in a fresh clone.
cmd = [
"git-filter-repo",
"--paths-from-file",
paths_file,
"--invert-paths",
"--force"
]
# Use log_and_execute but ensure console is visible for git-filter-repo's output
self.log_and_execute(
command=cmd,
working_directory=working_directory,
check=True, # This will raise GitCommandError if filter-repo fails
capture=True, # Capture output to log it
hide_console=False, # Show console for this potentially long-running, interactive-feeling tool
log_output_level=logging.INFO,
timeout_seconds=7200 # Allow a long timeout for large repos
)
def force_push_all(self, working_directory: str, remote_name: str) -> None:
"""Force pushes all local branches to the specified remote."""
func_name = "force_push_all"
log_handler.log_warning(f"Force pushing all branches to remote '{remote_name}'...", func_name=func_name)
cmd = ["git", "push", remote_name, "--all", "--force"]
# Esegui in modalità che possa mostrare finestre di dialogo per le credenziali
self.log_and_execute(
command=cmd,
working_directory=working_directory,
check=True,
capture=True, # Continuiamo a catturare l'output per i log
hide_console=False # <<< MODIFICA CHIAVE: Permette l'interazione
)
def force_push_tags(self, working_directory: str, remote_name: str) -> None:
"""Force pushes all local tags to the specified remote."""
func_name = "force_push_tags"
log_handler.log_warning(f"Force pushing all tags to remote '{remote_name}'...", func_name=func_name)
cmd = ["git", "push", remote_name, "--tags", "--force"]
# Esegui in modalità che possa mostrare finestre di dialogo per le credenziali
self.log_and_execute(
command=cmd,
working_directory=working_directory,
check=True,
capture=True, # Continuiamo a catturare l'output per i log
hide_console=False # <<< MODIFICA CHIAVE: Permette l'interazione
)
def set_branch_upstream(self, working_directory: str, branch_name: str, remote_name: str) -> None:
"""
Sets the upstream for a local branch to track a remote branch with the same name.
Args:
working_directory (str): The path to the Git repository.
branch_name (str): The local branch name.
remote_name (str): The name of the remote (e.g., 'origin').
Raises:
GitCommandError: If the command fails.
"""
func_name = "set_branch_upstream"
upstream_ref = f"{remote_name}/{branch_name}"
log_handler.log_debug(
f"Setting upstream for branch '{branch_name}' to '{upstream_ref}'",
func_name=func_name
)
cmd = ["git", "branch", f"--set-upstream-to={upstream_ref}", branch_name]
self.log_and_execute(
command=cmd,
working_directory=working_directory,
check=True
)

View File

@ -0,0 +1,331 @@
# --- FILE: gitsync_tool/core/history_cleaner.py ---
import os
import shutil
import tempfile
import subprocess
from typing import Dict, List, Any, Tuple, Optional
# Importa usando il percorso assoluto dal pacchetto
from gitutility.logging_setup import log_handler
from gitutility.commands.git_commands import GitCommands, GitCommandError
class HistoryCleaner:
"""
Handles the analysis and purging of unwanted files from a Git repository's history.
This class orchestrates the use of 'git-filter-repo' for safe history rewriting.
"""
def __init__(self, git_commands: GitCommands):
"""
Initializes the HistoryCleaner.
Args:
git_commands (GitCommands): An instance for executing Git commands.
Raises:
TypeError: If git_commands is not a valid GitCommands instance.
"""
if not isinstance(git_commands, GitCommands):
raise TypeError("HistoryCleaner requires a GitCommands instance.")
self.git_commands: GitCommands = git_commands
log_handler.log_debug("HistoryCleaner initialized.", func_name="__init__")
@staticmethod
def _check_filter_repo_installed() -> bool:
"""
Checks if 'git-filter-repo' is installed and accessible in the system's PATH.
Returns:
bool: True if git-filter-repo is found, False otherwise.
"""
func_name = "_check_filter_repo_installed"
try:
# Execute with --version, which is a lightweight command.
# Use subprocess.run directly to avoid circular dependencies or complex setups.
subprocess.run(
["git-filter-repo", "--version"],
check=True,
capture_output=True,
text=True,
# On Windows, prevent console window from flashing
startupinfo=(
subprocess.STARTUPINFO(dwFlags=subprocess.STARTF_USESHOWWINDOW)
if os.name == "nt"
else None
),
)
log_handler.log_info(
"'git-filter-repo' is installed and accessible.", func_name=func_name
)
return True
except FileNotFoundError:
log_handler.log_error(
"'git-filter-repo' command not found. It must be installed and in the system's PATH.",
func_name=func_name,
)
return False
except (subprocess.CalledProcessError, Exception) as e:
log_handler.log_error(
f"Error checking for 'git-filter-repo': {e}", func_name=func_name
)
return False
def analyze_repo_for_purgeable_files(
self, repo_path: str
) -> List[Dict[str, Any]]:
"""
Analyzes the entire repository history to find committed files that
are now covered by .gitignore rules.
Args:
repo_path (str): The absolute path to the Git repository.
Returns:
List[Dict[str, Any]]: A list of dictionaries, where each dictionary
represents a file to be purged and contains
'path' and 'size' keys.
"""
func_name = "analyze_repo_for_purgeable_files"
log_handler.log_info(
f"Starting history analysis for purgeable files in '{repo_path}'...",
func_name=func_name,
)
purge_candidates: Dict[str, int] = {} # Use dict to store unique paths
try:
# 1. Get a list of all blobs (file versions) in the repository's history
# Returns a list of (hash, path) tuples
all_blobs = self.git_commands.list_all_historical_blobs(repo_path)
if not all_blobs:
log_handler.log_info(
"No historical file blobs found. Analysis complete.",
func_name=func_name,
)
return []
log_handler.log_debug(
f"Found {len(all_blobs)} total blobs. Checking against .gitignore...",
func_name=func_name,
)
# 2. Iterate and find files that are now ignored
for blob_hash, file_path in all_blobs:
# Avoid reprocessing a path we already identified as a candidate
if file_path in purge_candidates:
continue
# Check if the current .gitignore would ignore this path
if self.git_commands.check_if_would_be_ignored(repo_path, file_path):
# It's a candidate for purging. Get its size.
try:
blob_size = self.git_commands.get_blob_size(
repo_path, blob_hash
)
# Store the file path and its size. If a path appears multiple
# times with different hashes, we'll just keep the first one found.
purge_candidates[file_path] = blob_size
log_handler.log_debug(
f"Candidate for purge: '{file_path}' (Size: {blob_size} bytes)",
func_name=func_name,
)
except GitCommandError as size_err:
log_handler.log_warning(
f"Could not get size for blob {blob_hash} ('{file_path}'): {size_err}",
func_name=func_name,
)
# 3. Format the results for the GUI
# Convert dict to the list of dicts format
result_list = [
{"path": path, "size": size}
for path, size in purge_candidates.items()
]
# Sort by size descending for better presentation
result_list.sort(key=lambda x: x["size"], reverse=True)
log_handler.log_info(
f"Analysis complete. Found {len(result_list)} unique purgeable file paths.",
func_name=func_name,
)
return result_list
except (GitCommandError, ValueError) as e:
log_handler.log_error(
f"Analysis failed due to a Git command error: {e}", func_name=func_name
)
raise # Re-raise to be handled by the async worker
except Exception as e:
log_handler.log_exception(
f"An unexpected error occurred during repository analysis: {e}",
func_name=func_name,
)
raise
def purge_files_from_history(
self,
repo_path: str,
files_to_remove: List[str],
remote_name: str,
remote_url: str,
) -> Tuple[bool, str]:
"""
Rewrites the repository's history to completely remove the specified files.
This is a DESTRUCTIVE operation.
Args:
repo_path (str): The absolute path to the Git repository.
files_to_remove (List[str]): A list of file paths to purge.
remote_name (str): The name of the remote to force-push to after cleaning.
remote_url (str): The URL of the remote, needed to re-add it after cleaning.
Returns:
Tuple[bool, str]: A tuple of (success_status, message).
"""
func_name = "purge_files_from_history"
log_handler.log_warning(
f"--- DESTRUCTIVE OPERATION STARTED: Purging {len(files_to_remove)} file paths from history in '{repo_path}' ---",
func_name=func_name,
)
# 1. Prerequisite check
if not self._check_filter_repo_installed():
error_msg = "'git-filter-repo' is not installed. This tool is required to safely clean the repository history."
log_handler.log_critical(error_msg, func_name=func_name)
return False, error_msg
if not files_to_remove:
return True, "No files were specified for removal. No action taken."
if not remote_url:
return False, "Remote URL is required to re-configure the remote after cleaning, but it was not provided."
# 2. Use a temporary file to list the paths for git-filter-repo
# This is safer than passing many arguments on the command line.
try:
with tempfile.NamedTemporaryFile(
mode="w", delete=False, encoding="utf-8", suffix=".txt"
) as tmp_file:
tmp_file_path = tmp_file.name
for file_path in files_to_remove:
# git-filter-repo expects paths to be literals, one per line
tmp_file.write(f"{file_path}\n")
log_handler.log_info(
f"Created temporary file with paths to remove: {tmp_file_path}",
func_name=func_name,
)
# 3. Run git-filter-repo
self.git_commands.run_filter_repo(repo_path, paths_file=tmp_file_path)
log_handler.log_info(
"History rewriting with git-filter-repo completed successfully.",
func_name=func_name,
)
# 4. ---<<< NUOVO PASSAGGIO CORRETTIVO >>>---
# Ri-aggiungi il remote che git-filter-repo ha rimosso.
log_handler.log_info(
f"Re-adding remote '{remote_name}' with URL '{remote_url}' after filtering...",
func_name=func_name,
)
# Dobbiamo prima verificare se esiste già (in rari casi potrebbe non essere rimosso).
# Se esiste, lo aggiorniamo, altrimenti lo aggiungiamo.
existing_remotes = self.git_commands.get_remotes(repo_path)
if remote_name in existing_remotes:
self.git_commands.set_remote_url(repo_path, remote_name, remote_url)
else:
self.git_commands.add_remote(repo_path, remote_name, remote_url)
log_handler.log_info(
f"Remote '{remote_name}' successfully re-configured.",
func_name=func_name
)
# ---<<< FINE NUOVO PASSAGGIO >>>---
# 5. Force push the rewritten history to the remote
log_handler.log_warning(
f"Force-pushing rewritten history to remote '{remote_name}'...",
func_name=func_name,
)
# 5. Force push the rewritten history to the remote
log_handler.log_warning(
f"Force-pushing rewritten history to remote '{remote_name}'...",
func_name=func_name,
)
# --- Get list of local branches before push ---
# Questo ci serve per sapere quali branch riconfigurare dopo
local_branches_before_push, _ = self.git_commands.list_branches(repo_path)
# Force push all branches
self.git_commands.force_push_all(repo_path, remote_name)
log_handler.log_info(
f"Force-pushed all branches to remote '{remote_name}'.",
func_name=func_name,
)
# Force push all tags
self.git_commands.force_push_tags(repo_path, remote_name)
log_handler.log_info(
f"Force-pushed all tags to remote '{remote_name}'.", func_name=func_name
)
# 6. ---<<< NUOVO PASSAGGIO CORRETTIVO 2 >>>---
# Re-establish upstream tracking for all local branches that were pushed.
log_handler.log_info(
"Re-establishing upstream tracking for local branches...",
func_name=func_name
)
for branch_name in local_branches_before_push:
try:
self.git_commands.set_branch_upstream(repo_path, branch_name, remote_name)
log_handler.log_debug(
f"Successfully set upstream for branch '{branch_name}' to '{remote_name}/{branch_name}'.",
func_name=func_name
)
except GitCommandError as upstream_err:
# Logga un avviso ma non far fallire l'intera operazione per questo.
# Potrebbe accadere se un branch locale non ha una controparte remota.
log_handler.log_warning(
f"Could not set upstream for branch '{branch_name}'. It might not exist on the remote. Error: {upstream_err}",
func_name=func_name
)
log_handler.log_info("Upstream tracking re-established.", func_name=func_name)
success_message = (
f"Successfully purged {len(files_to_remove)} file paths from history "
f"and force-pushed to remote '{remote_name}'.\n\n"
"IMPORTANT: Any other clones of this repository are now out of sync."
)
log_handler.log_info(success_message, func_name=func_name)
return True, success_message
except (GitCommandError, ValueError) as e:
error_msg = f"History cleaning failed: {e}"
log_handler.log_error(error_msg, func_name=func_name)
return False, error_msg
except Exception as e:
error_msg = f"An unexpected error occurred during history cleaning: {e}"
log_handler.log_exception(error_msg, func_name=func_name)
return False, error_msg
finally:
# Clean up the temporary file
if "tmp_file_path" in locals() and os.path.exists(tmp_file_path):
try:
os.remove(tmp_file_path)
log_handler.log_debug(
f"Cleaned up temporary file: {tmp_file_path}",
func_name=func_name,
)
except OSError as e:
log_handler.log_warning(
f"Could not remove temporary file {tmp_file_path}: {e}",
func_name=func_name,
)

View File

@ -76,10 +76,8 @@ class MainFrame(ttk.Frame):
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[
[Optional[str], Optional[str]], None
], # Modificato per accettare override
refresh_branches_cb: Callable[[], None],
checkout_branch_cb: Callable[[Optional[str], Optional[str]], None],
create_branch_cb: Callable[[], None],
refresh_changed_files_cb: Callable[[], None],
open_diff_viewer_cb: Callable[[str], None],
@ -98,12 +96,13 @@ class MainFrame(ttk.Frame):
merge_local_branch_cb: Callable[[str], None],
compare_branch_with_current_cb: Callable[[str], None],
view_commit_details_cb: Callable[[str], None],
# Altre dipendenze
config_manager_instance: Any, # Evita import ConfigManager qui
profile_sections_list: List[str],
# Automation Callbacks
update_gitea_wiki_cb: Callable[[], None],
analyze_and_clean_history_cb: Callable[[], None], # <<< NUOVO PARAMETRO AGGIUNTO QUI
# Altre dipendenze
config_manager_instance: Any,
profile_sections_list: List[str],
):
# ---<<< FINE MODIFICA >>>---
"""Initializes the MainFrame."""
super().__init__(master)
self.master: tk.Misc = master
@ -147,6 +146,7 @@ class MainFrame(ttk.Frame):
self.compare_branch_with_current_callback = compare_branch_with_current_cb
self.view_commit_details_callback = view_commit_details_cb
self.update_gitea_wiki_callback = update_gitea_wiki_cb
self.analyze_and_clean_history_cb = analyze_and_clean_history_cb
# Store references needed internally
self.config_manager = (
@ -650,43 +650,61 @@ class MainFrame(ttk.Frame):
def _create_automation_tab(self):
"""Creates the Automation tab content."""
frame = ttk.Frame(self.notebook, padding=(10, 10))
# Puoi usare Grid, Pack o Place all'interno di questo frame
frame.columnconfigure(0, weight=1) # Permette ai LabelFrame di espandersi orizzontalmente
# Container per le azioni Wiki
# --- Wiki Synchronization Section ---
wiki_frame = ttk.LabelFrame(
frame, text="Gitea Wiki Synchronization", padding=(10, 5)
)
# Usa pack per layout verticale semplice, o grid se preferisci
wiki_frame.pack(pady=5, padx=5, fill="x", anchor="n")
wiki_frame.grid(row=0, column=0, sticky="ew", pady=(0, 10))
# Descrizione (opzionale)
ttk.Label(
wiki_frame,
text="Update Gitea Wiki pages using local files from the 'doc/' directory.",
wraplength=450, # Per andare a capo
wraplength=450,
justify=tk.LEFT
).pack(pady=(0, 10), fill="x")
).pack(pady=(0, 10), fill="x", expand=True)
# Pulsante per l'aggiornamento Wiki
# Il comando sarà collegato al callback passato da app.py
self.update_wiki_button = ttk.Button(
wiki_frame,
text="Update Gitea Wiki Now",
command=self.update_gitea_wiki_callback, # Assicurati che il nome callback corrisponda
state=tk.DISABLED # Inizialmente disabilitato
command=self.update_gitea_wiki_callback,
state=tk.DISABLED
)
self.update_wiki_button.pack(pady=5, anchor="center") # Centra il pulsante
self.update_wiki_button.pack(pady=5)
self.create_tooltip(
self.update_wiki_button,
"Clones the associated Gitea Wiki repo, copies 'doc/Manual*.md' files,\n"
"commits the changes, and pushes them to the remote wiki."
)
# --- SPAZIO PER FUTURE AUTOMAZIONI ---
# Puoi aggiungere altri LabelFrame o widget qui sotto per altre azioni
# future_action_frame = ttk.LabelFrame(frame, text="Future Automation", ...)
# future_action_frame.pack(...)
# ttk.Button(future_action_frame, text="Do Something Else", ...).pack()
# --- History Cleaning Section ---
history_frame = ttk.LabelFrame(
frame, text="Repository History Maintenance", padding=(10, 5)
)
history_frame.grid(row=1, column=0, sticky="ew")
ttk.Label(
history_frame,
text="Analyze repository for committed files that should be ignored and offer to purge them from history.",
wraplength=450,
justify=tk.LEFT
).pack(pady=(0, 10), fill="x", expand=True)
# --- NUOVO PULSANTE ---
self.analyze_history_button = ttk.Button(
history_frame,
text="Analyze & Clean History...",
command=self.analyze_and_clean_history_cb, # Nuovo callback
state=tk.DISABLED,
style="Danger.TButton" # Stile per attirare l'attenzione
)
self.analyze_history_button.pack(pady=5)
self.create_tooltip(
self.analyze_history_button,
"DESTRUCTIVE: Analyze history for files to remove.\n"
"This action can rewrite the entire repository history."
)
return frame

View File

@ -0,0 +1,201 @@
# --- FILE: gitsync_tool/gui/purge_dialog.py ---
import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
from typing import List, Dict, Any, Optional
# Importa usando il percorso assoluto
from gitutility.logging_setup import log_handler
from gitutility.gui.tooltip import Tooltip
def _format_size(size_bytes: int) -> str:
"""Formats a size in bytes to a human-readable string (KB, MB, GB)."""
if size_bytes < 1024:
return f"{size_bytes} B"
kb = size_bytes / 1024
if kb < 1024:
return f"{kb:.1f} KB"
mb = kb / 1024
if mb < 1024:
return f"{mb:.2f} MB"
gb = mb / 1024
return f"{gb:.2f} GB"
class PurgeConfirmationDialog(simpledialog.Dialog):
"""
A modal dialog to show files that can be purged from Git history and
to get explicit user confirmation for this destructive action.
"""
def __init__(
self,
master: tk.Misc,
files_to_purge: List[Dict[str, Any]],
repo_path: str,
title: str = "Confirm History Purge",
):
"""
Initialize the dialog.
Args:
master: The parent window.
files_to_purge: A list of dicts, each with 'path' and 'size' keys.
repo_path: The path of the repository being cleaned.
title (str): The title for the dialog window.
"""
self.files_to_purge = files_to_purge
self.repo_path = repo_path
self.confirmation_var = tk.BooleanVar(value=False)
self.result: bool = False # The final result will be a boolean
super().__init__(master, title=title)
def body(self, master: tk.Frame) -> Optional[tk.Widget]:
"""Creates the dialog body."""
# Main container frame
container = ttk.Frame(master, padding="10")
container.pack(fill=tk.BOTH, expand=True)
# --- 1. Warning Section ---
warning_frame = ttk.Frame(container, style="Card.TFrame", padding=10)
warning_frame.pack(fill=tk.X, pady=(0, 10))
# Use a custom style for the warning frame if available
try:
s = ttk.Style()
s.configure("Card.TFrame", background="#FFF5F5", relief="solid", borderwidth=1)
except tk.TclError:
pass # Fallback to default frame style
warning_icon = ttk.Label(warning_frame, text="⚠️", font=("Segoe UI", 16))
warning_icon.pack(side=tk.LEFT, padx=(0, 10), anchor="n")
warning_text_frame = ttk.Frame(warning_frame, style="Card.TFrame")
warning_text_frame.pack(side=tk.LEFT, fill=tk.X, expand=True)
ttk.Label(
warning_text_frame,
text="DESTRUCTIVE ACTION",
font=("Segoe UI", 11, "bold"),
foreground="#D9534F",
style="Card.TLabel"
).pack(anchor="w")
warning_message = (
"This will permanently remove the files listed below from your entire Git history. "
"This operation rewrites history and requires a force push, which can disrupt collaboration.\n"
"Ensure all team members have pushed their changes before proceeding."
)
ttk.Label(
warning_text_frame,
text=warning_message,
wraplength=500,
justify=tk.LEFT,
style="Card.TLabel"
).pack(anchor="w", pady=(5, 0))
try:
s.configure("Card.TLabel", background="#FFF5F5")
except tk.TclError:
pass
# --- 2. File List Section ---
files_frame = ttk.LabelFrame(
container, text=f"Files to Purge ({len(self.files_to_purge)} found)", padding=10
)
files_frame.pack(fill=tk.BOTH, expand=True, pady=10)
files_frame.rowconfigure(0, weight=1)
files_frame.columnconfigure(0, weight=1)
columns = ("path", "size")
self.tree = ttk.Treeview(files_frame, columns=columns, show="headings")
self.tree.heading("path", text="File Path", anchor="w")
self.tree.heading("size", text="Size", anchor="e")
self.tree.column("path", width=400, stretch=tk.YES, anchor="w")
self.tree.column("size", width=100, stretch=tk.NO, anchor="e")
scrollbar = ttk.Scrollbar(files_frame, orient=tk.VERTICAL, command=self.tree.yview)
self.tree.configure(yscrollcommand=scrollbar.set)
self.tree.grid(row=0, column=0, sticky="nsew")
scrollbar.grid(row=0, column=1, sticky="ns")
# Populate the treeview
for file_info in self.files_to_purge:
path = file_info.get("path", "N/A")
size_bytes = file_info.get("size", 0)
formatted_size = _format_size(size_bytes)
self.tree.insert("", tk.END, values=(path, formatted_size))
# --- 3. Confirmation Checkbox Section ---
confirm_frame = ttk.Frame(container, padding=(0, 10, 0, 0))
confirm_frame.pack(fill=tk.X)
self.confirm_check = ttk.Checkbutton(
confirm_frame,
text="I understand the risks and want to permanently delete these files from history.",
variable=self.confirmation_var,
command=self._on_confirm_toggle,
)
self.confirm_check.pack(anchor="w")
return self.tree # Initial focus on the list
def _on_confirm_toggle(self):
"""Enables/disables the 'Confirm' button based on the checkbox state."""
# Find the OK button in the buttonbox
ok_button = self.ok_button
if ok_button:
if self.confirmation_var.get():
ok_button.config(state=tk.NORMAL)
else:
ok_button.config(state=tk.DISABLED)
def buttonbox(self):
"""Creates the OK and Cancel buttons."""
box = ttk.Frame(self)
self.ok_button = ttk.Button(
box,
text="Confirm and Purge",
width=20,
command=self.ok,
state=tk.DISABLED, # Initially disabled
style="Danger.TButton"
)
self.ok_button.pack(side=tk.LEFT, padx=5, pady=5)
try:
s = ttk.Style()
s.configure("Danger.TButton", foreground="white", background="#D9534F")
except tk.TclError:
pass # Fallback to default button style
Tooltip(self.ok_button, "This button is enabled only after you check the confirmation box.")
cancel_button = ttk.Button(
box, text="Cancel", width=10, command=self.cancel
)
cancel_button.pack(side=tk.LEFT, padx=5, pady=5)
self.bind("<Return>", lambda e: self.ok() if self.confirmation_var.get() else None)
self.bind("<Escape>", lambda e: self.cancel())
box.pack()
def validate(self) -> bool:
"""Validation is handled by the confirmation checkbox state."""
# The OK button is only enabled if the checkbox is checked, so if `ok` is
# called, we can assume the user has confirmed.
if not self.confirmation_var.get():
messagebox.showwarning(
"Confirmation Required",
"You must check the box to confirm you understand the risks before proceeding.",
parent=self,
)
return False
return True
def apply(self):
"""Sets the result to True as the action has been confirmed."""
self.result = True