add purge function
This commit is contained in:
parent
c84348b263
commit
32379dcc50
@ -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
@ -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
|
||||
@ -1512,6 +1512,85 @@ class AsyncResultHandler:
|
||||
# perché o il refresh lo farà, o il gestore generico lo farà se non ci sono 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 ---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
)
|
||||
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],
|
||||
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
|
||||
|
||||
|
||||
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