From 8f4bb30f7750d5da5595507475bf84d1bce68f23 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Mon, 7 Jul 2025 10:51:53 +0200 Subject: [PATCH] add size,fix problem access denied in export function --- export_folder/manifest.json | 212 ++++++++++++++++++++++++++++- reposync/config/profile_manager.py | 18 ++- reposync/core/git_manager.py | 48 ++----- reposync/core/sync_manager.py | 164 +++++----------------- reposync/gui/main_window.py | 192 ++++++++------------------ 5 files changed, 314 insertions(+), 320 deletions(-) diff --git a/export_folder/manifest.json b/export_folder/manifest.json index fa97dc8..4da171f 100644 --- a/export_folder/manifest.json +++ b/export_folder/manifest.json @@ -1,10 +1,87 @@ { - "export_timestamp": "2025-07-07T08:09:40.890434+00:00", + "export_timestamp": "2025-07-07T08:50:15.273042+00:00", "source_server_url": "http://192.168.100.10:3000", "repositories": { + "BackupTools": { + "bundle_file": "BackupTools.bundle", + "last_update": "2025-07-07T08:38:47.977102+00:00", + "branches": { + "master": "481c8e89ce0d153080ac220bafcdb19db95cf9f5" + }, + "description": "Backup Manager Pro is a user-friendly desktop application designed to help you easily create compressed backup archives (ZIP files) of your important folders. It offers a graphical interface to manage backup configurations, define exclusion rules, and organize different backup tasks using profiles.", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/BackupTools.git" + }, + "ControlPanel": { + "bundle_file": "ControlPanel.bundle", + "last_update": "2025-07-07T08:38:53.052755+00:00", + "branches": { + "SarRawData": "f4d7a723f34ad4eebd7dd05ca594678d236e3ca4" + }, + "description": "Control panel for view mfd and sar images from radar", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/ControlPanel.git" + }, + "CreateIconFromFilesPng": { + "bundle_file": "CreateIconFromFilesPng.bundle", + "last_update": "2025-07-07T08:38:53.355974+00:00", + "branches": { + "master": "f317b9c3128e8c4be3b4cd2dad8906b8492e85f4" + }, + "description": "Create icon files from png with alpha channel", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/CreateIconFromFilesPng.git" + }, + "DependencyAnalyzer": { + "bundle_file": "DependencyAnalyzer.bundle", + "last_update": "2025-07-07T08:38:53.666254+00:00", + "branches": { + "master": "7d572b882269090ff34f6c16e6e753f8553963d5" + }, + "description": "Un'applicazione GUI per analizzare le dipendenze di progetti Python, generare requirements.txt, scaricare pacchetti offline e confrontare ambienti.", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/DependencyAnalyzer.git" + }, + "FlightMonitor": { + "bundle_file": "FlightMonitor.bundle", + "last_update": "2025-07-07T08:38:56.315890+00:00", + "branches": { + "master": "82bf0afce4099cd4dc1bbbeb67b7eabde07d6b5b" + }, + "description": "Monitoraggio e salvataggio dei dati dei voli presenti in una determinata zona geografica", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/FlightMonitor.git" + }, + "GUI_g_reconverter": { + "bundle_file": "GUI_g_reconverter.bundle", + "last_update": "2025-07-07T08:38:56.646862+00:00", + "branches": { + "master": "0fbac920cae7250117793ba61796f0d3e7811669" + }, + "description": "Questo tool fornisce un'interfaccia grafica intuitiva (GUI) per configurare e lanciare conversioni, eliminando la necessit\u00e0 di gestire manualmente complesse righe di comando.", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/GUI_g_reconverter.git" + }, + "GeoElevation": { + "bundle_file": "GeoElevation.bundle", + "last_update": "2025-07-07T08:39:18.101643+00:00", + "branches": { + "master": "43a13a065844d05137f4b6c6e46132c0956e0a2f" + }, + "description": "find elevation for a specific lat/lon and region", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/GeoElevation.git" + }, "GitUtility": { "bundle_file": "GitUtility.bundle", - "last_update": "2025-07-07T08:09:39.966914+00:00", + "last_update": "2025-07-07T08:39:19.312491+00:00", "branches": { "master": "f29ddba84df2a56a9a487cd9a2af3c95fc2b28f5" }, @@ -15,18 +92,18 @@ }, "LauncherTool": { "bundle_file": "LauncherTool.bundle", - "last_update": "2025-07-07T08:09:40.243939+00:00", + "last_update": "2025-07-07T08:39:19.627972+00:00", "branches": { "master": "a04c599ced0e64f8f4b3c15e541ca97002484d8c" }, - "description": "", + "description": "lanciare applicazione in sequenza configurando opportuni parametri", "private": false, "owner": "vallongol", "clone_url": "http://192.168.100.10:3000/vallongol/LauncherTool.git" }, "MarkdownConverter": { "bundle_file": "MarkdownConverter.bundle", - "last_update": "2025-07-07T08:09:40.597156+00:00", + "last_update": "2025-07-07T08:39:19.997307+00:00", "branches": { "master": "db1e2cee0a8619fa5ede5b91d639061438d1beaf" }, @@ -35,16 +112,137 @@ "owner": "vallongol", "clone_url": "http://192.168.100.10:3000/vallongol/MarkdownConverter.git" }, + "ProfileAnalyzer": { + "bundle_file": "ProfileAnalyzer.bundle", + "last_update": "2025-07-07T08:39:20.760889+00:00", + "branches": { + "master": "ce5ec4981c5ca791af743eebae75f5d9571f7555" + }, + "description": "Analisi dei file di profilatura delle esecuzioni degli script python.", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/ProfileAnalyzer.git" + }, "ProjectInitializer": { "bundle_file": "ProjectInitializer.bundle", - "last_update": "2025-07-07T08:09:40.880740+00:00", + "last_update": "2025-07-07T08:39:21.068773+00:00", "branches": { "master": "126431a49a6a156146aaa562ed32b942972ed31c" }, + "description": "A Python application to initialize standard project structures for new Python projects, suitable for use with Git.", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/ProjectInitializer.git" + }, + "ProjectUtility": { + "bundle_file": "ProjectUtility.bundle", + "last_update": "2025-07-07T08:39:21.444956+00:00", + "branches": { + "master": "d063b16ec99f7d451256cb35db15eab3c72730c3" + }, + "description": "Framework per la gestione e lancio di singoli script con varie funzionalit\u00e0, scaricate da git", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/ProjectUtility.git" + }, + "PyInstallerGUIWrapper": { + "bundle_file": "PyInstallerGUIWrapper.bundle", + "last_update": "2025-07-07T08:39:21.824492+00:00", + "branches": { + "master": "c1f368fe07977a4755f08422e9b70bfa81d62dc4" + }, + "description": "Creazione di eseguibili e librerie che \u00e8 possibile utilizzare senza dover aver installato python e le sue dipendenze", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/PyInstallerGUIWrapper.git" + }, + "Radalyze": { + "bundle_file": "Radalyze.bundle", + "last_update": "2025-07-07T08:39:22.097315+00:00", + "branches": { + "master": "2073a820bb191862237d80bfa44b0f885a4b4f31" + }, "description": "", "private": false, "owner": "vallongol", - "clone_url": "http://192.168.100.10:3000/vallongol/ProjectInitializer.git" + "clone_url": "http://192.168.100.10:3000/vallongol/Radalyze.git" + }, + "Radian": { + "bundle_file": "Radian.bundle", + "last_update": "2025-07-07T08:39:22.387766+00:00", + "branches": { + "master": "a09ff98bfbf96c61c27b4ce5dd55d7d0961c616c" + }, + "description": "Framework per l'analisi dei dati di radar e simulazione", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/Radian.git" + }, + "RepoSync": { + "bundle_file": "RepoSync.bundle", + "last_update": "2025-07-07T08:39:22.717149+00:00", + "branches": { + "master": "fa547e378249a610be8d23290af5e71a566851d9" + }, + "description": "Software per la sincronizzazione di repository tra vari sistemi di gestione come gitea, github, ecc", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/RepoSync.git" + }, + "SamCtrlM_PSM_PX_SVN": { + "bundle_file": "SamCtrlM_PSM_PX_SVN.bundle", + "last_update": "2025-07-07T08:39:24.068402+00:00", + "branches": { + "master": "1c46bb8730c032daba185d2fff4036289385211a" + }, + "description": "", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/SamCtrlM_PSM_PX_SVN.git" + }, + "SecureTransferTool": { + "bundle_file": "SecureTransferTool.bundle", + "last_update": "2025-07-07T08:39:24.438210+00:00", + "branches": { + "master": "8d91bf8575fd6f4ef0c878832f314561287fb75d" + }, + "description": "", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/SecureTransferTool.git" + }, + "WorkspaceInspector": { + "bundle_file": "WorkspaceInspector.bundle", + "last_update": "2025-07-07T08:39:24.773438+00:00", + "branches": { + "master": "664b4692092044466950bcd94fb2a6a4e357d1ab" + }, + "description": "", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/WorkspaceInspector.git" + }, + "cpp_python_debug": { + "bundle_file": "cpp_python_debug.bundle", + "last_update": "2025-07-07T08:41:47.719399+00:00", + "branches": { + "master": "6c9ec6060e298409ea1e0073fbea864067c2db11" + }, + "description": "debug di applicazioni c++ mediante l'utilizzo di gdb.exe orchestrato da python che fornisce l'interfaccia", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/cpp_python_debug.git" + }, + "radar_data_reader": { + "bundle_file": "radar_data_reader.bundle", + "last_update": "2025-07-07T08:50:15.170227+00:00", + "branches": { + "master": "f2ada1755436d612a8dc1b172b1fc90534a98dc0" + }, + "description": "Lettura ed analisi dei file estratti dalla funzionalit\u00e0 del radar su host", + "private": false, + "owner": "vallongol", + "clone_url": "http://192.168.100.10:3000/vallongol/radar_data_reader.git" } } } \ No newline at end of file diff --git a/reposync/config/profile_manager.py b/reposync/config/profile_manager.py index 1b4dce1..2ed6d09 100644 --- a/reposync/config/profile_manager.py +++ b/reposync/config/profile_manager.py @@ -38,7 +38,10 @@ class ProfileManager: logger.info(f"Loading data from: {self.profiles_path}") if not self.profiles_path.exists(): self.profiles = {} - self.settings = {"bundle_path": str(Path.home() / "RepoSyncBundles")} + self.settings = { + "bundle_path": str(Path.home() / "RepoSyncBundles"), + "git_timeout": 600 # Default: 10 minutes + } logger.warning("Profiles file not found. Using default settings.") return @@ -49,14 +52,20 @@ class ProfileManager: self.profiles = data.get("profiles", {}) self.settings = data.get("settings", {}) + # Ensure defaults are set if they are missing from the file if "bundle_path" not in self.settings: self.settings["bundle_path"] = str(Path.home() / "RepoSyncBundles") + if "git_timeout" not in self.settings: + self.settings["git_timeout"] = 600 logger.info(f"Loaded {len(self.profiles)} profiles and app settings.") except (json.JSONDecodeError, IOError) as e: logger.error(f"Failed to load or parse profiles file: {e}. Starting fresh.") self.profiles = {} - self.settings = {"bundle_path": str(Path.home() / "RepoSyncBundles")} + self.settings = { + "bundle_path": str(Path.home() / "RepoSyncBundles"), + "git_timeout": 600 + } def save_data(self): """Saves both profiles and settings to the JSON file.""" @@ -85,16 +94,13 @@ class ProfileManager: def add_or_update_profile(self, name: str, data: Dict): """Adds a new profile or updates an existing one.""" - if not name.strip(): - raise ValueError("Profile name cannot be empty.") - logger.info(f"Adding/Updating profile: {name}") + if not name.strip(): raise ValueError("Profile name cannot be empty.") self.profiles[name] = data self.save_data() def delete_profile(self, name: str): """Deletes a profile by name.""" if name in self.profiles: - logger.info(f"Deleting profile: {name}") del self.profiles[name] self.save_data() diff --git a/reposync/core/git_manager.py b/reposync/core/git_manager.py index cdf8720..6b9b31b 100644 --- a/reposync/core/git_manager.py +++ b/reposync/core/git_manager.py @@ -47,9 +47,6 @@ class GitManager: def __init__(self, logger: logging.Logger): """ Initializes the GitManager with a logger instance. - - Args: - logger: A configured logging.Logger instance for logging operations. """ self.logger = logger self.logger.debug("GitManager initialized.") @@ -64,7 +61,6 @@ class GitManager: ) -> subprocess.CompletedProcess: """ Executes a shell command, logs details, and handles errors. - This is a private helper method for all git commands. """ command_str = " ".join(command) self.logger.debug(f"Executing in '{working_directory}': {command_str}") @@ -87,17 +83,12 @@ class GitManager: ) if check and result.returncode != 0: - error_message = ( - f"Git command failed with exit code {result.returncode}.\n" - f"Stderr: {result.stderr.strip()}" - ) + error_message = f"Git command failed with exit code {result.returncode}.\nStderr: {result.stderr.strip()}" self.logger.error(f"Command failed: {command_str}\n{error_message}") raise GitCommandError(error_message, command=command, stderr=result.stderr) - if result.stdout and result.stdout.strip(): - self.logger.debug(f"Command stdout: {result.stdout.strip()}") - if result.stderr and result.stderr.strip(): - self.logger.debug(f"Command stderr: {result.stderr.strip()}") + if result.stdout and result.stdout.strip(): self.logger.debug(f"Command stdout: {result.stdout.strip()}") + if result.stderr and result.stderr.strip(): self.logger.debug(f"Command stderr: {result.stderr.strip()}") return result @@ -114,39 +105,29 @@ class GitManager: self.logger.exception(msg) raise GitCommandError(msg, command=command) from e - def clone(self, remote_url: str, local_path: str, token: Optional[str] = None): + def clone(self, remote_url: str, local_path: str, token: Optional[str] = None, timeout: int = 300): """ Clones a repository from a remote URL to a local path. - Injects the token into the URL for HTTPS authentication if provided. """ self.logger.info(f"Preparing to clone from '{remote_url}' into '{local_path}'...") parent_dir = os.path.dirname(local_path) - if not os.path.exists(parent_dir): - os.makedirs(parent_dir, exist_ok=True) + if not os.path.exists(parent_dir): os.makedirs(parent_dir, exist_ok=True) url_to_clone = remote_url if token and remote_url.startswith("http"): try: parsed_url = urlparse(remote_url) - # The pattern is https://@domain/path - # We use the token itself as the user, as this is a common pattern for Git tokens. netloc_with_token = f"{token}@{parsed_url.hostname}" - if parsed_url.port: - netloc_with_token += f":{parsed_url.port}" - - # Reconstruct the URL with the token - url_to_clone = urlunparse( - (parsed_url.scheme, netloc_with_token, parsed_url.path, - parsed_url.params, parsed_url.query, parsed_url.fragment) - ) + if parsed_url.port: netloc_with_token += f":{parsed_url.port}" + url_to_clone = urlunparse((parsed_url.scheme, netloc_with_token, parsed_url.path, parsed_url.params, parsed_url.query, parsed_url.fragment)) self.logger.debug("Using URL with injected token for cloning.") except Exception as e: self.logger.error(f"Failed to parse and inject token into URL: {e}. Using original URL.") url_to_clone = remote_url command = ["git", "clone", "--progress", url_to_clone, local_path] - self._execute(command, working_directory=parent_dir, timeout=300) + self._execute(command, working_directory=parent_dir, timeout=timeout) self.logger.info(f"Repository cloned successfully to '{local_path}'.") def create_git_bundle(self, repo_path: str, bundle_path: str): @@ -160,7 +141,6 @@ class GitManager: """Gets a dictionary of local branches and their corresponding HEAD commit hashes.""" self.logger.debug(f"Getting local branches and HEADs for '{repo_path}'...") command = ["git", "branch", "--format=%(refname:short) %(objectname)"] - try: result = self._execute(command, working_directory=repo_path) branches = {} @@ -169,7 +149,6 @@ class GitManager: if len(parts) == 2: branch_name, commit_hash = parts branches[branch_name] = commit_hash - self.logger.debug(f"Found branches and heads: {branches}") return branches except GitCommandError as e: @@ -180,8 +159,7 @@ class GitManager: """Fetches updates from a remote or a bundle file.""" self.logger.info(f"Fetching from '{remote}'...") command = ["git", "fetch", remote] - if prune and os.path.isdir(remote): - command.append("--prune") + if prune and os.path.isdir(remote): command.append("--prune") self._execute(command, working_directory=repo_path) self.logger.info(f"Fetch from '{remote}' complete.") @@ -189,14 +167,10 @@ class GitManager: """Pushes changes to a remote.""" self.logger.info(f"Pushing to remote '{remote}'...") command = ["git", "push", remote] - if all_branches: - command.append("--all") - if all_tags: - command.append("--tags") - + if all_branches: command.append("--all") + if all_tags: command.append("--tags") if not all_branches and not all_tags: self.logger.warning("Push command called without specifying what to push. Use all_branches or all_tags.") return - self._execute(command, working_directory=repo_path, timeout=300) self.logger.info("Push complete.") \ No newline at end of file diff --git a/reposync/core/sync_manager.py b/reposync/core/sync_manager.py index 126bf5c..2cfe102 100644 --- a/reposync/core/sync_manager.py +++ b/reposync/core/sync_manager.py @@ -28,39 +28,19 @@ def _remove_readonly(func, path, exc_info): raise class SyncState(Enum): - """Enumeration for the synchronization status of a repository.""" - IDENTICAL = auto() - AHEAD = auto() - BEHIND = auto() - DIVERGED = auto() - NEW_REPO = auto() - ORPHANED_BUNDLE = auto() - ERROR = auto() + IDENTICAL = auto(); AHEAD = auto(); BEHIND = auto(); DIVERGED = auto(); NEW_REPO = auto(); ORPHANED_BUNDLE = auto(); ERROR = auto() class SyncManager: - """ - Manages two-phase synchronization using a manifest for state comparison. - """ - - def __init__( - self, - git_manager: GitManager, - vcs_client: BaseVCSClient, - sync_bundle_path: Path, - logger: logging.Logger, - ): + def __init__(self, git_manager: GitManager, vcs_client: BaseVCSClient, sync_bundle_path: Path, logger: logging.Logger): self.git_manager = git_manager self.vcs_client = vcs_client self.sync_bundle_path = sync_bundle_path self.logger = logger - self.temp_clone_path = self.sync_bundle_path / "temp_clones" self.manifest_path = self.sync_bundle_path / "manifest.json" - self.logger.debug("SyncManager for offline sync initialized.") def _write_manifest(self, manifest_data: Dict[str, Any]): - """Writes the manifest data to a JSON file.""" self.logger.info(f"Writing manifest file to: {self.manifest_path}") try: with open(self.manifest_path, "w", encoding="utf-8") as f: @@ -73,20 +53,12 @@ class SyncManager: def export_repositories_to_bundles( self, source_repos: List[Dict], + git_timeout: int, progress_callback: Optional[Callable[[int, int, int], None]] = None ): - """ - Exports repositories to .bundle files and creates a manifest.json. - - Args: - source_repos: A list of repository dictionaries from the source VCS. - progress_callback: A function to call after each repo is processed. - It receives (current_index, total_repos, bundle_size_bytes). - """ self.logger.info(f"Starting export of {len(source_repos)} repositories...") self.sync_bundle_path.mkdir(exist_ok=True) self.temp_clone_path.mkdir(exist_ok=True) - manifest_repos = {} total_repos = len(source_repos) @@ -95,168 +67,98 @@ class SyncManager: local_repo_path = self.temp_clone_path / repo_name bundle_file_path = self.sync_bundle_path / f"{repo_name}.bundle" bundle_size_bytes = 0 - self.logger.info(f"--- Exporting '{repo_name}' ({i+1}/{total_repos}) ---") try: - if local_repo_path.exists(): - shutil.rmtree(local_repo_path, onerror=_remove_readonly) + if local_repo_path.exists(): shutil.rmtree(local_repo_path, onerror=_remove_readonly) + if bundle_file_path.exists(): + self.logger.info(f"Removing existing bundle file: {bundle_file_path}") + os.remove(bundle_file_path) self.git_manager.clone( remote_url=repo["clone_url"], local_path=str(local_repo_path), - token=self.vcs_client.token + token=self.vcs_client.token, + timeout=git_timeout ) - self.git_manager.create_git_bundle(str(local_repo_path), str(bundle_file_path)) + if bundle_file_path.exists(): bundle_size_bytes = bundle_file_path.stat().st_size - if bundle_file_path.exists(): - bundle_size_bytes = bundle_file_path.stat().st_size - branches = self.git_manager.get_branches_with_heads(str(local_repo_path)) manifest_repos[repo_name] = { "bundle_file": bundle_file_path.name, "last_update": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "branches": branches, - "description": repo.get("description", ""), - "private": repo.get("private", True), - "owner": repo.get("owner"), + "branches": branches, "description": repo.get("description", ""), + "private": repo.get("private", True), "owner": repo.get("owner"), "clone_url": repo.get("clone_url") } - except (GitCommandError, Exception) as e: self.logger.error(f"Failed to export repository '{repo_name}': {e}", exc_info=True) finally: - if local_repo_path.exists(): - shutil.rmtree(local_repo_path, onerror=_remove_readonly) - - # Call the progress callback regardless of success or failure - if progress_callback: - progress_callback(i + 1, total_repos, bundle_size_bytes) + if local_repo_path.exists(): shutil.rmtree(local_repo_path, onerror=_remove_readonly) + if progress_callback: progress_callback(i + 1, total_repos, bundle_size_bytes) - full_manifest = { - "export_timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "source_server_url": self.vcs_client.api_url, - "repositories": manifest_repos, - } + full_manifest = {"export_timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), "source_server_url": self.vcs_client.api_url, "repositories": manifest_repos} self._write_manifest(full_manifest) self.logger.info("Export process finished.") def compare_bundles_with_remote(self) -> List[Dict[str, Any]]: - """Compares bundles in the sync path with the remote VCS using the manifest.""" self.logger.info("Comparing local bundles with remote server state...") - if not self.manifest_path.exists(): - self.logger.warning("manifest.json not found. Cannot compare state.") - return [] - - with open(self.manifest_path, "r", encoding="utf-8") as f: - manifest = json.load(f) - + if not self.manifest_path.exists(): return [] + with open(self.manifest_path, "r", encoding="utf-8") as f: manifest = json.load(f) comparison_results = [] manifest_repos = manifest.get("repositories", {}) - for repo_name, bundle_info in manifest_repos.items(): - self.logger.debug(f"Comparing state for '{repo_name}'...") - owner = bundle_info.get("owner") if not owner: - self.logger.error(f"Missing owner for '{repo_name}' in manifest. Skipping.") comparison_results.append({"name": repo_name, "state": SyncState.ERROR, "details": "Missing owner."}) continue - remote_repo = self.vcs_client.get_repository(repo_name) - remote_branches = self.vcs_client.get_repository_branches(owner, repo_name) - bundle_branches = bundle_info.get("branches", {}) - if not remote_repo: comparison_results.append({"name": repo_name, "state": SyncState.NEW_REPO, "bundle_info": bundle_info}) continue - + remote_branches = self.vcs_client.get_repository_branches(owner, repo_name) + bundle_branches = bundle_info.get("branches", {}) all_branches = set(bundle_branches.keys()) | set(remote_branches.keys()) - is_identical = True - is_ahead = False - is_behind = False - + is_identical, is_ahead, is_behind = True, False, False for branch in all_branches: - bundle_hash = bundle_branches.get(branch) - remote_hash = remote_branches.get(branch) - + bundle_hash, remote_hash = bundle_branches.get(branch), remote_branches.get(branch) if bundle_hash != remote_hash: is_identical = False - if bundle_hash and not remote_hash: - is_ahead = True - elif not bundle_hash and remote_hash: - is_behind = True - else: - is_ahead = True - is_behind = True - + if bundle_hash and not remote_hash: is_ahead = True + elif not bundle_hash and remote_hash: is_behind = True + else: is_ahead, is_behind = True, True state = SyncState.IDENTICAL if not is_identical: - if is_ahead and not is_behind: - state = SyncState.AHEAD - elif not is_ahead and is_behind: - state = SyncState.BEHIND - else: - state = SyncState.DIVERGED - + if is_ahead and not is_behind: state = SyncState.AHEAD + elif not is_ahead and is_behind: state = SyncState.BEHIND + else: state = SyncState.DIVERGED comparison_results.append({"name": repo_name, "state": state, "bundle_info": bundle_info}) - return comparison_results - def import_repositories_from_bundles(self, repos_to_import: List[Dict]): - """Imports or updates selected repositories from their bundle files.""" - # This function could also be updated with a progress callback if needed. - # For now, we focus on the export part. + def import_repositories_from_bundles(self, repos_to_import: List[Dict], git_timeout: int): self.logger.info(f"Starting import of {len(repos_to_import)} selected repositories...") self.temp_clone_path.mkdir(exist_ok=True) - for repo_info in repos_to_import: repo_name = repo_info["name"] bundle_info = repo_info["bundle_info"] bundle_file = self.sync_bundle_path / bundle_info["bundle_file"] local_repo_path = self.temp_clone_path / repo_name - - if not bundle_file.exists(): - self.logger.error(f"Bundle file {bundle_file} for '{repo_name}' not found. Skipping.") - continue - + if not bundle_file.exists(): continue self.logger.info(f"--- Importing '{repo_name}' from bundle ---") try: dest_repo = self.vcs_client.get_repository(repo_name) if not dest_repo: - self.logger.info(f"'{repo_name}' not found on destination, creating...") - dest_repo = self.vcs_client.create_repository( - name=repo_name, - description=bundle_info.get("description", ""), - private=bundle_info.get("private", True) - ) - - if local_repo_path.exists(): - shutil.rmtree(local_repo_path, onerror=_remove_readonly) - + dest_repo = self.vcs_client.create_repository(name=repo_name, description=bundle_info.get("description", ""), private=bundle_info.get("private", True)) + if local_repo_path.exists(): shutil.rmtree(local_repo_path, onerror=_remove_readonly) clone_url = dest_repo.get("clone_url") if not clone_url: owner = dest_repo.get('owner') or bundle_info.get('owner') clone_url = f"{self.vcs_client.api_url}/{owner}/{repo_name}.git" - self.logger.warning(f"Could not find clone_url, constructed manually: {clone_url}") - - self.logger.info(f"Cloning '{repo_name}' from destination to set up remote...") - self.git_manager.clone( - remote_url=clone_url, - local_path=str(local_repo_path), - token=self.vcs_client.token - ) - - self.logger.info(f"Fetching updates from bundle for '{repo_name}'...") + self.git_manager.clone(remote_url=clone_url, local_path=str(local_repo_path), token=self.vcs_client.token, timeout=git_timeout) self.git_manager.fetch(repo_path=str(local_repo_path), remote=str(bundle_file)) - - self.logger.info(f"Pushing updates to destination for '{repo_name}'...") self.git_manager.push(str(local_repo_path), "origin", all_branches=True, all_tags=True) - except (GitCommandError, Exception) as e: self.logger.error(f"Failed to import repository '{repo_name}': {e}", exc_info=True) finally: - if local_repo_path.exists(): - shutil.rmtree(local_repo_path, onerror=_remove_readonly) - + if local_repo_path.exists(): shutil.rmtree(local_repo_path, onerror=_remove_readonly) self.logger.info("Import process finished.") \ No newline at end of file diff --git a/reposync/gui/main_window.py b/reposync/gui/main_window.py index b2c66ad..ff5f824 100644 --- a/reposync/gui/main_window.py +++ b/reposync/gui/main_window.py @@ -23,17 +23,15 @@ from ..utility import logger as logger_util def format_size(size_kb: int) -> str: """Formats size in kilobytes to a human-readable string (KB, MB, GB).""" - if size_kb < 1024: - return f"{size_kb} KB" - elif size_kb < 1024 * 1024: - return f"{size_kb / 1024:.2f} MB" - else: - return f"{size_kb / (1024 * 1024):.2f} GB" + if not isinstance(size_kb, (int, float)) or size_kb < 0: return "0 KB" + if size_kb < 1024: return f"{size_kb} KB" + if size_kb < 1024 * 1024: return f"{size_kb / 1024:.2f} MB" + return f"{size_kb / (1024 * 1024):.2f} GB" def format_bytes(size_bytes: int) -> str: """Formats size in bytes to a human-readable string (B, KB, MB, GB).""" - if size_bytes < 1024: - return f"{size_bytes} B" + if not isinstance(size_bytes, (int, float)) or size_bytes < 0: return "0 B" + if size_bytes < 1024: return f"{size_bytes} B" return format_size(size_bytes // 1024) @@ -48,7 +46,6 @@ class BaseTab(ttk.Frame): self.sync_bundle_path = None def create_profile_selector(self, parent_frame, label_text): - """Creates the profile selection Combobox and status label.""" selector_frame = ttk.Frame(parent_frame) ttk.Label(selector_frame, text=label_text).pack(side="left", padx=(0, 5)) self.profile_combo = ttk.Combobox(selector_frame, textvariable=self.selected_profile_name, state="readonly", width=25) @@ -59,7 +56,6 @@ class BaseTab(ttk.Frame): return selector_frame def update_profile_list(self): - """Updates the list of profiles in the Combobox.""" profile_names = self.app.profile_manager.get_profile_names() self.profile_combo['values'] = sorted(profile_names) if not profile_names: @@ -67,12 +63,10 @@ class BaseTab(ttk.Frame): self._update_status_label(False, "No profiles configured.") def update_bundle_path(self): - """Updates the bundle path from the main application's setting.""" new_path_str = self.app.bundle_path_var.get() self.sync_bundle_path = Path(new_path_str) if new_path_str else None def _on_profile_select(self, event=None): - """Handles profile selection and initializes the appropriate client.""" profile_name = self.selected_profile_name.get() if not profile_name: self.active_client = None @@ -81,7 +75,6 @@ class BaseTab(ttk.Frame): profile_data = self.app.profile_manager.get_profile(profile_name) if not profile_data: self.active_client = None - self.logger.error(f"Could not load data for profile '{profile_name}'.") self._update_status_label(False, "Profile data missing.") return service_type = profile_data.get("type", "gitea") @@ -90,26 +83,20 @@ class BaseTab(ttk.Frame): self.active_client = GiteaClient(api_url=profile_data["url"], token=profile_data["token"], logger=logging.getLogger(f"GiteaClient({profile_name})")) self._update_status_label(True, f"Selected: {profile_data['url']}") except Exception as e: - self.logger.error(f"Failed to initialize GiteaClient for '{profile_name}': {e}") - self._update_status_label(False, f"Error initializing client.") + self._update_status_label(False, "Error initializing client.") else: - self.logger.error(f"Unsupported service type '{service_type}' for profile '{profile_name}'.") self._update_status_label(False, f"Unsupported type: {service_type}") def _update_status_label(self, is_connected, message=""): - """Updates the status label text and color.""" - if is_connected: - self.status_label.config(text=message or "Status: Connected", foreground="green") - else: - self.status_label.config(text=message or "Status: Not Connected", foreground="red") + if is_connected: self.status_label.config(text=message or "Status: Connected", foreground="green") + else: self.status_label.config(text=message or "Status: Not Connected", foreground="red") class ExportTab(BaseTab): """Tab for fetching repos from a server and exporting them to bundles.""" def __init__(self, parent, app_instance, **kwargs): super().__init__(parent, app_instance, "ExportTab", **kwargs) - self.repositories = [] - self.repo_data_map = {} + self.repositories, self.repo_data_map = [], {} self.selection_count_var = tk.StringVar(value="Selected: 0") self.selection_size_var = tk.StringVar(value="Total Est. Size: 0 KB") self.total_export_size = 0 @@ -117,49 +104,25 @@ class ExportTab(BaseTab): self.update_profile_list() def _create_widgets(self): - top_frame = ttk.Frame(self, padding="5") - top_frame.pack(side="top", fill="x") - - selector_frame = self.create_profile_selector(top_frame, "Export from Server:") - selector_frame.pack(side="left") - - self.fetch_button = ttk.Button(top_frame, text="Fetch Repositories", command=self._start_repo_fetch) - self.fetch_button.pack(side="left", padx=10) - self.fetch_button.state(['disabled']) - - self.export_button = ttk.Button(top_frame, text="Export Selected", command=self._start_export) - self.export_button.pack(side="left", padx=5) - self.export_button.state(['disabled']) - - # Treeview to display repositories - tree_frame = ttk.Frame(self) - tree_frame.pack(expand=True, fill="both", pady=5) - + top_frame = ttk.Frame(self, padding="5"); top_frame.pack(side="top", fill="x") + selector_frame = self.create_profile_selector(top_frame, "Export from Server:"); selector_frame.pack(side="left") + self.fetch_button = ttk.Button(top_frame, text="Fetch Repositories", command=self._start_repo_fetch); self.fetch_button.pack(side="left", padx=10); self.fetch_button.state(['disabled']) + tree_frame = ttk.Frame(self); tree_frame.pack(expand=True, fill="both", pady=5) columns = ("name", "size", "description", "private") self.repo_tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="extended") self.repo_tree.heading("name", text="Name"); self.repo_tree.heading("size", text="Size (est.)"); self.repo_tree.heading("description", text="Description"); self.repo_tree.heading("private", text="Private") self.repo_tree.column("name", width=200, stretch=tk.NO); self.repo_tree.column("size", width=100, stretch=tk.NO, anchor="e"); self.repo_tree.column("description", width=500); self.repo_tree.column("private", width=80, stretch=tk.NO, anchor="center") - - vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.repo_tree.yview) - self.repo_tree.configure(yscrollcommand=vsb.set) - vsb.pack(side="right", fill="y") - self.repo_tree.pack(side="left", expand=True, fill="both") - + vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.repo_tree.yview); self.repo_tree.configure(yscrollcommand=vsb.set); vsb.pack(side="right", fill="y"); self.repo_tree.pack(side="left", expand=True, fill="both") self.repo_tree.bind("<>", self._update_selection_info) - - # --- Selection Info Frame --- - info_frame = ttk.LabelFrame(self, text="Selection Info", padding="5") - info_frame.pack(side="bottom", fill="x", pady=(5, 0)) - - selection_buttons_frame = ttk.Frame(info_frame) - selection_buttons_frame.pack(side="left", padx=5) - + info_frame = ttk.LabelFrame(self, text="Selection Info & Actions", padding="5"); info_frame.pack(side="bottom", fill="x", pady=(5, 0)) + selection_buttons_frame = ttk.Frame(info_frame); selection_buttons_frame.pack(side="left", padx=5) ttk.Button(selection_buttons_frame, text="Select All", command=self._select_all).pack(side="left") ttk.Button(selection_buttons_frame, text="Select None", command=self._deselect_all).pack(side="left", padx=5) + labels_frame = ttk.Frame(info_frame); labels_frame.pack(side="left", padx=10) + ttk.Label(labels_frame, textvariable=self.selection_count_var).pack(anchor="w") + ttk.Label(labels_frame, textvariable=self.selection_size_var).pack(anchor="w") + self.export_button = ttk.Button(info_frame, text="Export Selected", command=self._start_export); self.export_button.pack(side="right", padx=10, pady=5); self.export_button.state(['disabled']) - ttk.Label(info_frame, textvariable=self.selection_count_var).pack(side="left", padx=10) - ttk.Label(info_frame, textvariable=self.selection_size_var).pack(side="left", padx=10) - def _on_profile_select(self, event=None): super()._on_profile_select(event) self.fetch_button.state(['!disabled'] if self.active_client else ['disabled']) @@ -167,102 +130,66 @@ class ExportTab(BaseTab): self.repo_tree.delete(*self.repo_tree.get_children()) self._update_selection_info() - def _select_all(self): - all_iids = self.repo_tree.get_children() - self.repo_tree.selection_set(all_iids) - # Event might not fire programmatically, so we call the update manually - self._update_selection_info() - - def _deselect_all(self): - self.repo_tree.selection_set([]) - self._update_selection_info() + def _select_all(self): self.repo_tree.selection_set(self.repo_tree.get_children()); self._update_selection_info() + def _deselect_all(self): self.repo_tree.selection_set([]); self._update_selection_info() def _update_selection_info(self, event=None): - """Updates the labels with count and total size of selected items.""" - selected_iids = self.repo_tree.selection() - count = len(selected_iids) - total_size_kb = 0 - for iid in selected_iids: - # Use the pre-populated map for efficient lookup - if iid in self.repo_data_map: - total_size_kb += self.repo_data_map[iid].get("size_kb", 0) - - self.selection_count_var.set(f"Selected: {count}") - self.selection_size_var.set(f"Total Est. Size: {format_size(total_size_kb)}") + selected_iids = self.repo_tree.selection(); count = len(selected_iids) + total_size_kb = sum(self.repo_data_map[iid].get("size_kb", 0) for iid in selected_iids if iid in self.repo_data_map) + self.selection_count_var.set(f"Selected: {count}"); self.selection_size_var.set(f"Total Est. Size: {format_size(total_size_kb)}") def _start_repo_fetch(self): if not self.active_client: return - self.logger.info(f"Fetching repository list from '{self.selected_profile_name.get()}'...") self.fetch_button.state(['disabled']); self.export_button.state(['disabled']) threading.Thread(target=self._load_repositories_thread, daemon=True).start() def _load_repositories_thread(self): try: self.repositories = self.active_client.get_repositories() - # Populate the data map for quick lookups self.repo_data_map = {repo['name']: repo for repo in self.repositories} self.app.root.after(10, self._update_repo_treeview) - except Exception as e: - self.logger.error(f"Failed to fetch repositories: {e}", exc_info=True) - messagebox.showerror("Fetch Error", f"Failed to fetch repositories:\n{e}") - finally: - self.app.root.after(10, lambda: self.fetch_button.state(['!disabled'])) + except Exception as e: messagebox.showerror("Fetch Error", f"Failed to fetch repositories:\n{e}") + finally: self.app.root.after(10, lambda: self.fetch_button.state(['!disabled'])) def _update_repo_treeview(self): self.repo_tree.delete(*self.repo_tree.get_children()) - for repo in self.repositories: - self.repo_tree.insert("", "end", values=(repo["name"], format_size(repo.get("size_kb", 0)), repo.get("description", ""), "Yes" if repo.get("private") else "No"), iid=repo["name"]) - self.logger.info(f"Displayed {len(self.repositories)} repositories.") + for repo in self.repositories: self.repo_tree.insert("", "end", values=(repo["name"], format_size(repo.get("size_kb", 0)), repo.get("description", ""), "Yes" if repo.get("private") else "No"), iid=repo["name"]) self.export_button.state(['!disabled'] if self.repositories else ['disabled']) - self._update_selection_info() # Reset labels + self._update_selection_info() def _start_export(self): selected_iids = self.repo_tree.selection() - if not selected_iids: return - if not self.sync_bundle_path: return + if not selected_iids or not self.sync_bundle_path: return repos_to_export = [repo for repo in self.repositories if repo["name"] in selected_iids] - sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager")) - self.logger.info(f"Starting export for {len(repos_to_export)} repositories...") self.export_button.state(['disabled']); self.fetch_button.state(['disabled']) - self.total_export_size = 0 - self.app.status_bar_progress.config(value=0) - self.app.status_bar_label.config(text="Starting export...") - - thread = threading.Thread(target=self._export_thread, args=(sync_manager, repos_to_export), daemon=True) - thread.start() + self.app.status_bar_progress.config(value=0); self.app.status_bar_label.config(text="Starting export...") + git_timeout = self.app.profile_manager.get_setting("git_timeout", 300) + threading.Thread(target=self._export_thread, args=(sync_manager, repos_to_export, git_timeout), daemon=True).start() def _progress_callback(self, current, total, bundle_size_bytes): - """Callback function to update the GUI from the export thread.""" - progress_percent = (current / total) * 100 - self.total_export_size += bundle_size_bytes - + progress_percent = (current / total) * 100; self.total_export_size += bundle_size_bytes self.app.status_bar_progress.config(value=progress_percent) self.app.status_bar_label.config(text=f"Exporting {current}/{total}...") self.app.status_bar_size_label.config(text=f"Total Size: {format_bytes(self.total_export_size)}") - def _export_thread(self, sync_manager, repos_to_export): + def _export_thread(self, sync_manager, repos_to_export, git_timeout): try: - callback_for_thread = lambda c, t, s: self.app.root.after(0, self._progress_callback, c, t, s) - sync_manager.export_repositories_to_bundles(repos_to_export, progress_callback=callback_for_thread) + callback = lambda c, t, s: self.app.root.after(0, self._progress_callback, c, t, s) + sync_manager.export_repositories_to_bundles(repos_to_export, git_timeout, progress_callback=callback) self.app.root.after(0, lambda: messagebox.showinfo("Success", "Export completed successfully.")) - except Exception as e: - self.logger.error(f"Export failed: {e}", exc_info=True) - self.app.root.after(0, lambda: messagebox.showerror("Export Error", f"An error occurred: {e}")) + except Exception as e: self.app.root.after(0, lambda: messagebox.showerror("Export Error", f"An error occurred: {e}")) finally: self.app.root.after(0, lambda: self.app.status_bar_label.config(text="Export finished.")) self.app.root.after(0, lambda: self.export_button.state(['!disabled'])) self.app.root.after(0, lambda: self.fetch_button.state(['!disabled'])) - class ImportTab(BaseTab): - # This class remains unchanged functionally def __init__(self, parent, app_instance, **kwargs): super().__init__(parent, app_instance, "ImportTab", **kwargs) self.comparison_results = [] - self._create_widgets() - self.update_profile_list() + self._create_widgets(); self.update_profile_list() def _create_widgets(self): top_frame = ttk.Frame(self, padding="5"); top_frame.pack(side="top", fill="x") selector_frame = self.create_profile_selector(top_frame, "Import to Server:"); selector_frame.pack(side="left") @@ -279,7 +206,7 @@ class ImportTab(BaseTab): def _start_bundle_check(self): if not self.active_client or not self.sync_bundle_path: return sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager")) - self.logger.info("Checking bundle status..."); self.check_status_button.state(['disabled']) + self.check_status_button.state(['disabled']) threading.Thread(target=self._check_status_thread, args=(sync_manager,), daemon=True).start() def _check_status_thread(self, sync_manager): try: @@ -295,14 +222,14 @@ class ImportTab(BaseTab): selected_iids = self.bundle_tree.selection() if not selected_iids: return repos_to_import = [r for r in self.comparison_results if r["name"] in selected_iids and r["state"] in [SyncState.AHEAD, SyncState.NEW_REPO]] - if not repos_to_import: messagebox.showwarning("Invalid Selection", "Please select repositories that are 'AHEAD' or 'NEW_REPO' to import."); return + if not repos_to_import: messagebox.showwarning("Invalid Selection", "Please select 'AHEAD' or 'NEW_REPO' repos."); return sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager")) - self.logger.info(f"Starting import for {len(repos_to_import)} repositories...") self.import_button.state(['disabled']); self.check_status_button.state(['disabled']) - threading.Thread(target=self._import_thread, args=(sync_manager, repos_to_import), daemon=True).start() - def _import_thread(self, sync_manager, repos_to_import): + git_timeout = self.app.profile_manager.get_setting("git_timeout", 300) + threading.Thread(target=self._import_thread, args=(sync_manager, repos_to_import, git_timeout), daemon=True).start() + def _import_thread(self, sync_manager, repos_to_import, git_timeout): try: - sync_manager.import_repositories_from_bundles(repos_to_import) + sync_manager.import_repositories_from_bundles(repos_to_import, git_timeout) messagebox.showinfo("Success", "Import process completed.") except Exception as e: messagebox.showerror("Import Error", f"An error occurred: {e}") finally: @@ -310,24 +237,15 @@ class ImportTab(BaseTab): self.app.root.after(10, lambda: self.check_status_button.state(['!disabled'])) self.app.root.after(10, self._start_bundle_check) - class Application: """The main class for the RepoSync graphical user interface.""" def __init__(self): - self.root = tk.Tk() - self.root.title("RepoSync - Offline Repository Synchronization") - self.root.geometry("1200x800") - self.profile_manager = ProfileManager() - self.git_manager = None - self.bundle_path_var = tk.StringVar() - self._create_menu() - self._create_main_widgets() - self._setup_logging() - self._init_backend() + self.root = tk.Tk(); self.root.title("RepoSync - Offline Repository Synchronization"); self.root.geometry("1200x800") + self.profile_manager = ProfileManager(); self.git_manager = None; self.bundle_path_var = tk.StringVar() + self._create_menu(); self._create_main_widgets(); self._setup_logging(); self._init_backend() self.root.protocol("WM_DELETE_WINDOW", self._on_closing) def _create_menu(self): - menubar = tk.Menu(self.root); self.root.config(menu=menubar) - file_menu = tk.Menu(menubar, tearoff=0) + menubar = tk.Menu(self.root); self.root.config(menu=menubar); file_menu = tk.Menu(menubar, tearoff=0) file_menu.add_command(label="Configure Profiles...", command=self._open_profile_dialog) file_menu.add_separator(); file_menu.add_command(label="Exit", command=self._on_closing) menubar.add_cascade(label="File", menu=file_menu) @@ -352,7 +270,6 @@ class Application: new_path = filedialog.askdirectory(title="Select Bundle Folder", initialdir=initial_dir) if new_path: self.bundle_path_var.set(new_path); self.profile_manager.set_setting("bundle_path", new_path) - self.logger.info(f"Bundle path updated to: {new_path}") self.export_tab.update_bundle_path(); self.import_tab.update_bundle_path() def _setup_logging(self): log_config = {"level": logging.INFO, "enable_console": True}; logger_util.setup_logging(self.log_widget, self.root, log_config); self.logger = logging.getLogger(__name__) @@ -366,9 +283,6 @@ class Application: def _open_profile_dialog(self): dialog = ProfileDialog(self.root, self.profile_manager) self.root.wait_window(dialog); self.update_all_profile_comboboxes() - def update_all_profile_comboboxes(self): - self.export_tab.update_profile_list(); self.import_tab.update_profile_list() - def _on_closing(self): - self.logger.info("Application is closing."); logger_util.shutdown_logging(); self.root.destroy() - def run(self): - self.logger.info("Starting RepoSync application..."); self.root.mainloop() \ No newline at end of file + def update_all_profile_comboboxes(self): self.export_tab.update_profile_list(); self.import_tab.update_profile_list() + def _on_closing(self): self.logger.info("Application is closing."); logger_util.shutdown_logging(); self.root.destroy() + def run(self): self.logger.info("Starting RepoSync application..."); self.root.mainloop() \ No newline at end of file