add size,fix problem access denied in export function
This commit is contained in:
parent
89f8245ea7
commit
8f4bb30f77
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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.")
|
||||
@ -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)
|
||||
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)
|
||||
|
||||
# Call the progress callback regardless of success or failure
|
||||
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.")
|
||||
@ -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:
|
||||
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,48 +104,24 @@ 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)
|
||||
|
||||
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)
|
||||
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'])
|
||||
|
||||
def _on_profile_select(self, event=None):
|
||||
super()._on_profile_select(event)
|
||||
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user