164 lines
8.4 KiB
Python
164 lines
8.4 KiB
Python
# 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
|
|
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 _remove_readonly(func, path, exc_info):
|
|
"""
|
|
Error handler for shutil.rmtree to handle read-only files on Windows.
|
|
"""
|
|
if not os.access(path, os.W_OK):
|
|
os.chmod(path, stat.S_IWRITE)
|
|
func(path)
|
|
else:
|
|
raise
|
|
|
|
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 = 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]):
|
|
self.logger.info(f"Writing manifest file to: {self.manifest_path}")
|
|
try:
|
|
with open(self.manifest_path, "w", encoding="utf-8") as f:
|
|
json.dump(manifest_data, f, indent=4)
|
|
self.logger.info("Manifest file created successfully.")
|
|
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],
|
|
git_timeout: int,
|
|
progress_callback: Optional[Callable[[int, int, int], None]] = None
|
|
):
|
|
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)
|
|
|
|
for i, repo in enumerate(source_repos):
|
|
repo_name = repo["name"]
|
|
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 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,
|
|
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
|
|
|
|
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")
|
|
}
|
|
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)
|
|
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}
|
|
self._write_manifest(full_manifest)
|
|
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(): 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():
|
|
owner = bundle_info.get("owner")
|
|
if not owner:
|
|
comparison_results.append({"name": repo_name, "state": SyncState.ERROR, "details": "Missing owner."})
|
|
continue
|
|
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_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, is_ahead, is_behind = 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], 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(): continue
|
|
self.logger.info(f"--- Importing '{repo_name}' from bundle ---")
|
|
try:
|
|
dest_repo = self.vcs_client.get_repository(repo_name)
|
|
if not dest_repo:
|
|
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.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.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)
|
|
self.logger.info("Import process finished.") |