# RepoSync/core/sync_manager.py """ Orchestrates the offline synchronization process using Git bundles and a manifest file. """ import datetime import json import logging import os import shutil import stat import time # Import the time module for sleep from enum import Enum, auto from pathlib import Path from typing import List, Dict, Any, Optional, Callable from .base_vcs_client import BaseVCSClient from .git_manager import GitManager, GitCommandError def robust_rmtree(path, max_retries=3, delay=0.1): """ A robust version of shutil.rmtree that handles read-only files and retries on PermissionError (file in use on Windows). """ for i in range(max_retries): try: shutil.rmtree(path, onerror=_remove_readonly_onerror) return # Success except PermissionError as e: if "used by another process" in str(e) and i < max_retries - 1: time.sleep(delay) # Wait and retry else: raise # Re-raise the final exception except FileNotFoundError: return # Path already gone, which is fine def _remove_readonly_onerror(func, path, exc_info): """ Error handler for shutil.rmtree. It's called when rmtree fails. It changes the file permissions to writable and retries the deletion. """ if not os.access(path, os.W_OK): os.chmod(path, stat.S_IWRITE) func(path) else: # If it's not a read-only error, just re-raise # The robust_rmtree will catch PermissionError raise exc_info[1] class SyncState(Enum): IDENTICAL = auto(); AHEAD = auto(); BEHIND = auto(); DIVERGED = auto(); NEW_REPO = auto(); ORPHANED_BUNDLE = auto(); ERROR = auto() class SyncManager: def __init__(self, git_manager: GitManager, vcs_client: BaseVCSClient, sync_bundle_path: Path, logger: logging.Logger): self.git_manager, self.vcs_client, self.sync_bundle_path, self.logger = git_manager, vcs_client, sync_bundle_path, logger self.temp_clone_path, self.manifest_path = self.sync_bundle_path / "temp_clones", self.sync_bundle_path / "manifest.json" self.logger.debug("SyncManager for offline sync initialized.") def _write_manifest(self, manifest_data: Dict[str, Any]): try: with open(self.manifest_path, "w", encoding="utf-8") as f: json.dump(manifest_data, f, indent=4) self.logger.info(f"Manifest file written successfully to: {self.manifest_path}") except IOError as e: self.logger.error(f"Failed to write manifest file: {e}"); raise def export_repositories_to_bundles(self, source_repos: List[Dict], progress_callback: Optional[Callable[[int, int, int], None]] = None): self.logger.info(f"Starting export of {len(source_repos)} items..."); self.sync_bundle_path.mkdir(exist_ok=True); self.temp_clone_path.mkdir(exist_ok=True) manifest_repos, total_repos, successes = {}, len(source_repos), 0 for i, repo in enumerate(source_repos): repo_name, local_repo_path = repo["name"], self.temp_clone_path / repo["name"] bundle_file_name = repo["name"].replace(" (Wiki)", ".wiki") + ".bundle" bundle_file_path, bundle_size_bytes, is_wiki = self.sync_bundle_path / bundle_file_name, 0, repo.get("is_wiki", False) self.logger.info(f"--- Processing '{repo_name}' ({i+1}/{total_repos}) ---") try: # Always clean up before starting if local_repo_path.exists(): robust_rmtree(local_repo_path) self.git_manager.clone(remote_url=repo["clone_url"], local_path=str(local_repo_path), token=self.vcs_client.token) 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 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"), "clone_url": repo.get("clone_url")} successes += 1 except GitCommandError as e: # --- NEW LOGIC: Handle empty/uninitialized wikis gracefully --- if is_wiki and ("Could not read from remote repository" in str(e) or "does not appear to be a git repository" in str(e)): self.logger.warning(f"Skipping empty or uninitialized wiki for '{repo_name}'. It will not be included in the manifest.") else: # For all other critical errors, log them fully self.logger.error(f"Failed to process repository '{repo_name}': {e}", exc_info=True) except Exception as e: # Catch any other unexpected errors self.logger.error(f"An unexpected error occurred while processing '{repo_name}': {e}", exc_info=True) finally: if local_repo_path.exists(): robust_rmtree(local_repo_path) if progress_callback: progress_callback(i + 1, total_repos, bundle_size_bytes) if successes > 0: 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) else: self.logger.error("Export process finished with no successful exports. Manifest file was NOT written or updated.") self.logger.info("Export process finished.") def compare_bundles_with_remote(self) -> List[Dict[str, Any]]: 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 [] try: with open(self.manifest_path, "r", encoding="utf-8") as f: manifest = json.load(f) except (json.JSONDecodeError, IOError) as e: self.logger.error(f"Could not read or parse manifest file: {e}"); return [] 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}'...") remote_repo = self.vcs_client.get_repository(repo_name) if not remote_repo: comparison_results.append({"name": repo_name, "state": SyncState.NEW_REPO, "bundle_info": bundle_info}); continue remote_owner = remote_repo.get("owner") if not remote_owner: comparison_results.append({"name": repo_name, "state": SyncState.ERROR, "details": "Could not determine remote owner."}); continue remote_branches = self.vcs_client.get_repository_branches(remote_owner, repo_name) bundle_branches = bundle_info.get("branches", {}) all_branches, is_identical, is_ahead, is_behind = set(bundle_branches.keys()) | set(remote_branches.keys()), True, False, False for branch in all_branches: 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, 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 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]): 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, bundle_info = repo_info["name"], repo_info["bundle_info"] bundle_file, local_repo_path = self.sync_bundle_path / bundle_info["bundle_file"], self.temp_clone_path / repo_name is_wiki = " (Wiki)" in repo_name if not bundle_file.exists(): self.logger.error(f"Bundle file {bundle_file} for '{repo_name}' not found. Skipping."); continue self.logger.info(f"--- Importing '{repo_name}' from bundle ---") try: is_new_repo = False base_repo_name = repo_name.removesuffix(" (Wiki)").strip() dest_repo = self.vcs_client.get_repository(base_repo_name) if not dest_repo: self.logger.info(f"Base repository '{base_repo_name}' not found, creating...") dest_repo = self.vcs_client.create_repository(name=base_repo_name, description=bundle_info.get("description", ""), private=bundle_info.get("private", True)) is_new_repo = True if is_wiki: self.logger.info(f"Ensuring wiki is enabled for '{base_repo_name}'...") self.vcs_client.enable_wiki(dest_repo["owner"], base_repo_name) if local_repo_path.exists(): robust_rmtree(local_repo_path) self.logger.info(f"Cloning '{repo_name}' directly from bundle file...") self.git_manager.clone_from_bundle(str(bundle_file), str(local_repo_path)) dest_owner = dest_repo.get("owner") dest_base_url = self.vcs_client.api_url dest_clone_url = f"{dest_base_url}/{dest_owner}/{base_repo_name}.wiki.git" if is_wiki else f"{dest_base_url}/{dest_owner}/{base_repo_name}.git" self.logger.info(f"Setting remote 'origin' to destination URL: {dest_clone_url}") auth_clone_url = self.git_manager.get_url_with_token(dest_clone_url, self.vcs_client.token) self.git_manager.set_remote_url(str(local_repo_path), "origin", auth_clone_url) self.logger.info(f"Pushing all content to destination for '{repo_name}'...") self.git_manager.push(str(local_repo_path), "origin", all_branches=True, all_tags=True) if is_new_repo and not is_wiki: bundle_branches = bundle_info.get("branches", {}) default_branch = "main" if "main" in bundle_branches else "master" if default_branch in bundle_branches: self.vcs_client.set_default_branch(dest_repo["owner"], base_repo_name, default_branch) else: self.logger.warning(f"Could not determine a default branch for '{base_repo_name}'. Please set it manually.") except (GitCommandError, Exception) as e: self.logger.error(f"Failed to import repository '{repo_name}': {e}", exc_info=True); raise finally: if local_repo_path.exists(): robust_rmtree(local_repo_path) self.logger.info("Import process finished.")