add size,fix problem access denied in export function

This commit is contained in:
VALLONGOL 2025-07-07 10:51:53 +02:00
parent 89f8245ea7
commit 8f4bb30f77
5 changed files with 314 additions and 320 deletions

View File

@ -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"
}
}
}

View File

@ -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()

View File

@ -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://<token>@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.")

View File

@ -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.")

View File

@ -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("<<TreeviewSelect>>", 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()
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()