add purge function
This commit is contained in:
parent
c84348b263
commit
32379dcc50
@ -1,46 +1,6 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|
||||||
import os
|
import os
|
||||||
a = Analysis(scripts=['gitutility\\__main__.py'],
|
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)
|
||||||
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)
|
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
|
||||||
|
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')
|
||||||
exe = EXE(pyz,
|
coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='GitUtility')
|
||||||
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')
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -16,7 +16,6 @@ from typing import (
|
|||||||
Set,
|
Set,
|
||||||
) # Aggiunto Set
|
) # Aggiunto Set
|
||||||
|
|
||||||
# ---<<< MODIFICA IMPORT >>>---
|
|
||||||
# Usa percorsi assoluti per importare moduli dal pacchetto
|
# Usa percorsi assoluti per importare moduli dal pacchetto
|
||||||
from gitutility.logging_setup import log_handler
|
from gitutility.logging_setup import log_handler
|
||||||
from gitutility.commands.git_commands import GitCommandError
|
from gitutility.commands.git_commands import GitCommandError
|
||||||
@ -33,7 +32,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
# Importa MainFrame dal nuovo percorso
|
# Importa MainFrame dal nuovo percorso
|
||||||
from gitutility.gui.main_frame import MainFrame
|
from gitutility.gui.main_frame import MainFrame
|
||||||
# ---<<< FINE MODIFICA IMPORT >>>---
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncResultHandler:
|
class AsyncResultHandler:
|
||||||
@ -138,6 +136,8 @@ class AsyncResultHandler:
|
|||||||
"get_commit_details": self._handle_get_commit_details_result,
|
"get_commit_details": self._handle_get_commit_details_result,
|
||||||
"update_wiki": self._handle_generic_result,
|
"update_wiki": self._handle_generic_result,
|
||||||
"revert_to_tag": self._handle_revert_to_tag_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
|
# Get the handler method from the map
|
||||||
@ -1513,5 +1513,84 @@ class AsyncResultHandler:
|
|||||||
|
|
||||||
return trigger_refreshes, sync_refresh
|
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 ---
|
# --- End of AsyncResultHandler Class ---
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2039,5 +2039,144 @@ class GitCommands:
|
|||||||
# Rilancia l'eccezione
|
# Rilancia l'eccezione
|
||||||
raise e
|
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
|
||||||
|
)
|
||||||
331
gitutility/core/history_cleaner.py
Normal file
331
gitutility/core/history_cleaner.py
Normal 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,
|
||||||
|
)
|
||||||
@ -76,10 +76,8 @@ class MainFrame(ttk.Frame):
|
|||||||
checkout_tag_cb: Callable[[], None],
|
checkout_tag_cb: Callable[[], None],
|
||||||
revert_to_tag_cb: Callable[[], None],
|
revert_to_tag_cb: Callable[[], None],
|
||||||
refresh_history_cb: Callable[[], None],
|
refresh_history_cb: Callable[[], None],
|
||||||
refresh_branches_cb: Callable[[], None], # Callback unico per refresh locali
|
refresh_branches_cb: Callable[[], None],
|
||||||
checkout_branch_cb: Callable[
|
checkout_branch_cb: Callable[[Optional[str], Optional[str]], None],
|
||||||
[Optional[str], Optional[str]], None
|
|
||||||
], # Modificato per accettare override
|
|
||||||
create_branch_cb: Callable[[], None],
|
create_branch_cb: Callable[[], None],
|
||||||
refresh_changed_files_cb: Callable[[], None],
|
refresh_changed_files_cb: Callable[[], None],
|
||||||
open_diff_viewer_cb: Callable[[str], None],
|
open_diff_viewer_cb: Callable[[str], None],
|
||||||
@ -98,12 +96,13 @@ class MainFrame(ttk.Frame):
|
|||||||
merge_local_branch_cb: Callable[[str], None],
|
merge_local_branch_cb: Callable[[str], None],
|
||||||
compare_branch_with_current_cb: Callable[[str], None],
|
compare_branch_with_current_cb: Callable[[str], None],
|
||||||
view_commit_details_cb: Callable[[str], None],
|
view_commit_details_cb: Callable[[str], None],
|
||||||
# Altre dipendenze
|
# Automation Callbacks
|
||||||
config_manager_instance: Any, # Evita import ConfigManager qui
|
|
||||||
profile_sections_list: List[str],
|
|
||||||
update_gitea_wiki_cb: Callable[[], None],
|
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."""
|
"""Initializes the MainFrame."""
|
||||||
super().__init__(master)
|
super().__init__(master)
|
||||||
self.master: tk.Misc = 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.compare_branch_with_current_callback = compare_branch_with_current_cb
|
||||||
self.view_commit_details_callback = view_commit_details_cb
|
self.view_commit_details_callback = view_commit_details_cb
|
||||||
self.update_gitea_wiki_callback = update_gitea_wiki_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
|
# Store references needed internally
|
||||||
self.config_manager = (
|
self.config_manager = (
|
||||||
@ -650,43 +650,61 @@ class MainFrame(ttk.Frame):
|
|||||||
def _create_automation_tab(self):
|
def _create_automation_tab(self):
|
||||||
"""Creates the Automation tab content."""
|
"""Creates the Automation tab content."""
|
||||||
frame = ttk.Frame(self.notebook, padding=(10, 10))
|
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(
|
wiki_frame = ttk.LabelFrame(
|
||||||
frame, text="Gitea Wiki Synchronization", padding=(10, 5)
|
frame, text="Gitea Wiki Synchronization", padding=(10, 5)
|
||||||
)
|
)
|
||||||
# Usa pack per layout verticale semplice, o grid se preferisci
|
wiki_frame.grid(row=0, column=0, sticky="ew", pady=(0, 10))
|
||||||
wiki_frame.pack(pady=5, padx=5, fill="x", anchor="n")
|
|
||||||
|
|
||||||
# Descrizione (opzionale)
|
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
wiki_frame,
|
wiki_frame,
|
||||||
text="Update Gitea Wiki pages using local files from the 'doc/' directory.",
|
text="Update Gitea Wiki pages using local files from the 'doc/' directory.",
|
||||||
wraplength=450, # Per andare a capo
|
wraplength=450,
|
||||||
justify=tk.LEFT
|
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(
|
self.update_wiki_button = ttk.Button(
|
||||||
wiki_frame,
|
wiki_frame,
|
||||||
text="Update Gitea Wiki Now",
|
text="Update Gitea Wiki Now",
|
||||||
command=self.update_gitea_wiki_callback, # Assicurati che il nome callback corrisponda
|
command=self.update_gitea_wiki_callback,
|
||||||
state=tk.DISABLED # Inizialmente disabilitato
|
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.create_tooltip(
|
||||||
self.update_wiki_button,
|
self.update_wiki_button,
|
||||||
"Clones the associated Gitea Wiki repo, copies 'doc/Manual*.md' files,\n"
|
"Clones the associated Gitea Wiki repo, copies 'doc/Manual*.md' files,\n"
|
||||||
"commits the changes, and pushes them to the remote wiki."
|
"commits the changes, and pushes them to the remote wiki."
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- SPAZIO PER FUTURE AUTOMAZIONI ---
|
# --- History Cleaning Section ---
|
||||||
# Puoi aggiungere altri LabelFrame o widget qui sotto per altre azioni
|
history_frame = ttk.LabelFrame(
|
||||||
# future_action_frame = ttk.LabelFrame(frame, text="Future Automation", ...)
|
frame, text="Repository History Maintenance", padding=(10, 5)
|
||||||
# future_action_frame.pack(...)
|
)
|
||||||
# ttk.Button(future_action_frame, text="Do Something Else", ...).pack()
|
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
|
return frame
|
||||||
|
|
||||||
|
|||||||
201
gitutility/gui/purge_dialog.py
Normal file
201
gitutility/gui/purge_dialog.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user