fix import, add wiki export

This commit is contained in:
VALLONGOL 2025-07-10 15:38:26 +02:00
parent c262ab7680
commit 8e68e15937
5 changed files with 563 additions and 219 deletions

View File

@ -1,10 +1,10 @@
{ {
"export_timestamp": "2025-07-10T09:38:38.293280+00:00", "export_timestamp": "2025-07-10T11:39:41.502153+00:00",
"source_server_url": "http://192.168.100.10:3000", "source_server_url": "http://192.168.100.10:3000",
"repositories": { "repositories": {
"BackupTools": { "BackupTools": {
"bundle_file": "BackupTools.bundle", "bundle_file": "BackupTools.bundle",
"last_update": "2025-07-10T09:38:38.292950+00:00", "last_update": "2025-07-10T11:28:29.616675+00:00",
"branches": { "branches": {
"master": "481c8e89ce0d153080ac220bafcdb19db95cf9f5" "master": "481c8e89ce0d153080ac220bafcdb19db95cf9f5"
}, },
@ -12,6 +12,380 @@
"private": false, "private": false,
"owner": "vallongol", "owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/BackupTools.git" "clone_url": "http://192.168.100.10:3000/vallongol/BackupTools.git"
},
"BackupTools (Wiki)": {
"bundle_file": "BackupTools.wiki.bundle",
"last_update": "2025-07-10T11:28:29.857379+00:00",
"branches": {
"main": "45d45f62b9110ff2ce20c3394430e998050e7db6"
},
"description": "Wiki for the 'BackupTools' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/BackupTools.wiki.git"
},
"ControlPanel": {
"bundle_file": "ControlPanel.bundle",
"last_update": "2025-07-10T11:28:30.228095+00:00",
"branches": {
"SarRawData": "a441ced1e4a34bdac34a6bbd826947872bf7f2cb"
},
"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"
},
"ControlPanel (Wiki)": {
"bundle_file": "ControlPanel.wiki.bundle",
"last_update": "2025-07-10T11:28:30.506731+00:00",
"branches": {
"main": "f24d2dd58849b3f9a59baed5317f21e7c04bf6f1"
},
"description": "Wiki for the 'ControlPanel' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/ControlPanel.wiki.git"
},
"CreateIconFromFilesPng": {
"bundle_file": "CreateIconFromFilesPng.bundle",
"last_update": "2025-07-10T11:28:30.769289+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-10T11:28:31.139590+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-10T11:28:33.708421+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"
},
"FlightMonitor (Wiki)": {
"bundle_file": "FlightMonitor.wiki.bundle",
"last_update": "2025-07-10T11:28:34.027118+00:00",
"branches": {
"main": "b2e1764fa8a83d599ed87d08a53a0425a3951974"
},
"description": "Wiki for the 'FlightMonitor' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/FlightMonitor.wiki.git"
},
"GUI_g_reconverter": {
"bundle_file": "GUI_g_reconverter.bundle",
"last_update": "2025-07-10T11:28:34.363159+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"
},
"GUI_g_reconverter (Wiki)": {
"bundle_file": "GUI_g_reconverter.wiki.bundle",
"last_update": "2025-07-10T11:28:34.608094+00:00",
"branches": {
"main": "081464e735819a6b9674eef729ecf400e34fafd2"
},
"description": "Wiki for the 'GUI_g_reconverter' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/GUI_g_reconverter.wiki.git"
},
"GeoElevation": {
"bundle_file": "GeoElevation.bundle",
"last_update": "2025-07-10T11:28:52.994901+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"
},
"GeoElevation (Wiki)": {
"bundle_file": "GeoElevation.wiki.bundle",
"last_update": "2025-07-10T11:28:53.320234+00:00",
"branches": {
"main": "7785ea7b11bf63571e607bb7010c31cc79fc48f7"
},
"description": "Wiki for the 'GeoElevation' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/GeoElevation.wiki.git"
},
"GitUtility": {
"bundle_file": "GitUtility.bundle",
"last_update": "2025-07-10T11:28:54.537948+00:00",
"branches": {
"master": "16fe8af667ab9f88322986cf0e7c55079008e94e"
},
"description": "Utility per la gestione delle operazione su repository git",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/GitUtility.git"
},
"GitUtility (Wiki)": {
"bundle_file": "GitUtility.wiki.bundle",
"last_update": "2025-07-10T11:28:54.804705+00:00",
"branches": {
"main": "784d043c8b5e9e47b9743efcf73952b2cf7df28f"
},
"description": "Wiki for the 'GitUtility' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/GitUtility.wiki.git"
},
"LauncherTool": {
"bundle_file": "LauncherTool.bundle",
"last_update": "2025-07-10T11:28:55.131784+00:00",
"branches": {
"master": "a04c599ced0e64f8f4b3c15e541ca97002484d8c"
},
"description": "lanciare applicazione in sequenza configurando opportuni parametri",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/LauncherTool.git"
},
"LauncherTool (Wiki)": {
"bundle_file": "LauncherTool.wiki.bundle",
"last_update": "2025-07-10T11:28:55.426585+00:00",
"branches": {
"main": "c3844f16a26730d4783b9cef0813be90b3459af2"
},
"description": "Wiki for the 'LauncherTool' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/LauncherTool.wiki.git"
},
"MarkdownConverter": {
"bundle_file": "MarkdownConverter.bundle",
"last_update": "2025-07-10T11:28:55.799643+00:00",
"branches": {
"master": "db1e2cee0a8619fa5ede5b91d639061438d1beaf"
},
"description": "Conversione di file di tipo markdown in docx e pdf, anche con l'applicazione di template",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/MarkdownConverter.git"
},
"MarkdownConverter (Wiki)": {
"bundle_file": "MarkdownConverter.wiki.bundle",
"last_update": "2025-07-10T11:28:56.046003+00:00",
"branches": {
"main": "690a2b1cdab203023c6379bced02d8e13372997a"
},
"description": "Wiki for the 'MarkdownConverter' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/MarkdownConverter.wiki.git"
},
"ProfileAnalyzer": {
"bundle_file": "ProfileAnalyzer.bundle",
"last_update": "2025-07-10T11:28:56.873273+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"
},
"ProfileAnalyzer (Wiki)": {
"bundle_file": "ProfileAnalyzer.wiki.bundle",
"last_update": "2025-07-10T11:28:57.151129+00:00",
"branches": {
"main": "a8554d94d9c17748c42f31e9c4d9e7eec830c562"
},
"description": "Wiki for the 'ProfileAnalyzer' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/ProfileAnalyzer.wiki.git"
},
"ProjectInitializer": {
"bundle_file": "ProjectInitializer.bundle",
"last_update": "2025-07-10T11:28:57.476569+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"
},
"ProjectInitializer (Wiki)": {
"bundle_file": "ProjectInitializer.wiki.bundle",
"last_update": "2025-07-10T11:28:57.767439+00:00",
"branches": {
"main": "1db5644983f8e51255a8f7b5cf6967cb467c8033"
},
"description": "Wiki for the 'ProjectInitializer' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/ProjectInitializer.wiki.git"
},
"ProjectUtility": {
"bundle_file": "ProjectUtility.bundle",
"last_update": "2025-07-10T11:28:58.141516+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"
},
"ProjectUtility (Wiki)": {
"bundle_file": "ProjectUtility.wiki.bundle",
"last_update": "2025-07-10T11:28:58.468499+00:00",
"branches": {
"main": "fcae03bcef6a061d5a2137d1469c2122fe7f0be7"
},
"description": "Wiki for the 'ProjectUtility' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/ProjectUtility.wiki.git"
},
"PyInstallerGUIWrapper": {
"bundle_file": "PyInstallerGUIWrapper.bundle",
"last_update": "2025-07-10T11:28:58.827435+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"
},
"PyInstallerGUIWrapper (Wiki)": {
"bundle_file": "PyInstallerGUIWrapper.wiki.bundle",
"last_update": "2025-07-10T11:28:59.075697+00:00",
"branches": {
"main": "7b2367baf1594227f94e78b5095d67631998a9a1"
},
"description": "Wiki for the 'PyInstallerGUIWrapper' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/PyInstallerGUIWrapper.wiki.git"
},
"Radalyze": {
"bundle_file": "Radalyze.bundle",
"last_update": "2025-07-10T11:28:59.317702+00:00",
"branches": {
"master": "2073a820bb191862237d80bfa44b0f885a4b4f31"
},
"description": "",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/Radalyze.git"
},
"Radian": {
"bundle_file": "Radian.bundle",
"last_update": "2025-07-10T11:28:59.663951+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-10T11:29:00.046837+00:00",
"branches": {
"master": "c66e1affe482e187ab37693ba1213f4a5f144148"
},
"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-10T11:29:01.323377+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-10T11:29:01.810288+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-10T11:29:02.218689+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-10T11:31:22.744936+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"
},
"cpp_python_debug (Wiki)": {
"bundle_file": "cpp_python_debug.wiki.bundle",
"last_update": "2025-07-10T11:31:23.062025+00:00",
"branches": {
"main": "69c3557e4e8fbbb086ec02392577aef4e124391b"
},
"description": "Wiki for the 'cpp_python_debug' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/cpp_python_debug.wiki.git"
},
"radar_data_reader (Wiki)": {
"bundle_file": "radar_data_reader.wiki.bundle",
"last_update": "2025-07-10T11:39:41.497173+00:00",
"branches": {
"main": "d7b277b87efd441a6bed5d526ce421730ce5fc07"
},
"description": "Wiki for the 'radar_data_reader' repository",
"private": false,
"owner": "vallongol",
"clone_url": "http://192.168.100.10:3000/vallongol/radar_data_reader.wiki.git"
} }
} }
} }

View File

@ -69,7 +69,7 @@ class GitManager:
except Exception as e: except Exception as e:
self.logger.error(f"Failed to parse and inject token into URL: {e}. Using original URL.") self.logger.error(f"Failed to parse and inject token into URL: {e}. Using original URL.")
command = ["git", "clone", "--progress", url_to_clone, local_path] 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=900)
self.logger.info(f"Repository cloned successfully to '{local_path}'.") self.logger.info(f"Repository cloned successfully to '{local_path}'.")
def create_git_bundle(self, repo_path: str, bundle_path: str): def create_git_bundle(self, repo_path: str, bundle_path: str):

View File

@ -1,10 +1,5 @@
# RepoSync/core/gitea_client.py # RepoSync/core/gitea_client.py
# (File completo - versione corretta e semplificata)
"""
Gitea-specific implementation of the BaseVCSClient.
This module handles all interactions with a Gitea instance via its REST API.
"""
import logging import logging
import requests import requests
@ -22,164 +17,112 @@ class GiteaClient(BaseVCSClient):
Implements the abstract methods defined in BaseVCSClient. Implements the abstract methods defined in BaseVCSClient.
""" """
def __init__(self, api_url: str, token: str, logger: logging.Logger): def __init__(self, api_url: str, token: str, logger: logging.Logger):
"""
Initializes the GiteaClient.
"""
super().__init__(api_url, token) super().__init__(api_url, token)
self.logger = logger self.logger = logger
self.logger.debug(f"GiteaClient initialized for URL: {self.api_url}") self.logger.debug(f"GiteaClient initialized for URL: {self.api_url}")
def _api_request( def _api_request(self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, raise_on_404: bool = True) -> Any:
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
) -> Any:
"""
A helper method to make requests to the Gitea API.
"""
url = f"{self.api_url}/api/v1{endpoint}" url = f"{self.api_url}/api/v1{endpoint}"
self.logger.debug(f"Making API call: {method} {url}") self.logger.debug(f"Making API call: {method} {url}")
try: try:
response = requests.request( response = requests.request(method=method, url=url, headers=self.headers, params=params, json=json_data, timeout=30)
method=method, if not raise_on_404 and response.status_code == 404: return None
url=url,
headers=self.headers,
params=params,
json=json_data,
timeout=30
)
response.raise_for_status() response.raise_for_status()
return None if response.status_code == 204 else response.json()
if response.status_code == 204:
return None
return response.json()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.logger.error(f"Gitea API request failed: {e}") self.logger.error(f"Gitea API request failed: {e}")
raise GiteaAPIError(f"Failed to communicate with Gitea API: {e}") from e raise GiteaAPIError(f"Failed to communicate with Gitea API: {e}") from e
def get_repositories(self) -> List[Dict[str, Any]]: def get_repositories(self) -> List[Dict[str, Any]]:
""" self.logger.info("Fetching list of repositories and wikis from Gitea...")
Retrieves a list of all repositories accessible by the user, handling pagination.
"""
self.logger.info("Fetching list of repositories from Gitea...")
all_repos = [] all_repos = []
page = 1 page = 1
limit = 50 limit = 50
while True: while True:
self.logger.debug(f"Fetching page {page} of repositories...") self.logger.debug(f"Fetching page {page} of repositories...")
params = {"page": page, "limit": limit} params = {"page": page, "limit": limit}
repos_page = self._api_request("GET", "/user/repos", params=params) repos_page = self._api_request("GET", "/user/repos", params=params)
if not repos_page: break
if not repos_page:
break
for repo in repos_page: for repo in repos_page:
standardized_repo = { all_repos.append({
"name": repo.get("name"), "name": repo.get("name"), "owner": repo.get("owner", {}).get("login"),
"owner": repo.get("owner", {}).get("login"), "clone_url": repo.get("clone_url"), "description": repo.get("description"),
"clone_url": repo.get("clone_url"), "private": repo.get("private"), "size_kb": repo.get("size", 0), "is_wiki": False
"description": repo.get("description"), })
"private": repo.get("private"), # We trust the 'has_wiki' flag provided by the API.
"size_kb": repo.get("size", 0) if repo.get("has_wiki"):
} repo_name, clone_url = repo.get("name"), repo.get("clone_url")
all_repos.append(standardized_repo) if clone_url and clone_url.endswith(".git"):
wiki_clone_url = clone_url[:-4] + ".wiki.git"
all_repos.append({
"name": f"{repo_name} (Wiki)", "owner": repo.get("owner", {}).get("login"),
"clone_url": wiki_clone_url, "description": f"Wiki for the '{repo_name}' repository",
"private": repo.get("private"), "size_kb": 0, "is_wiki": True
})
page += 1 page += 1
self.logger.info(f"Found {len(all_repos)} items (repositories and wikis).")
self.logger.info(f"Found {len(all_repos)} repositories.")
return all_repos return all_repos
def get_repository(self, repo_name: str) -> Optional[Dict[str, Any]]: def get_repository(self, repo_name: str) -> Optional[Dict[str, Any]]:
""" self.logger.info(f"Searching for item '{repo_name}'...")
Retrieves a single repository by its name using the search endpoint. is_wiki, base_repo_name = repo_name.endswith(" (Wiki)"), repo_name.removesuffix(" (Wiki)").strip()
""" params = {"q": base_repo_name, "limit": 10}
self.logger.info(f"Searching for repository '{repo_name}'...")
params = {"q": repo_name, "limit": 10}
try: try:
search_result = self._api_request("GET", "/repos/search", params=params) search_result = self._api_request("GET", "/repos/search", params=params)
except GiteaAPIError as e: except GiteaAPIError as e:
self.logger.warning(f"Repo search failed: {e}. This may be a permissions issue.") self.logger.warning(f"Repo search failed: {e}."); return None
return None
if not search_result or not search_result.get("data"): if not search_result or not search_result.get("data"):
self.logger.info(f"Repository '{repo_name}' not found via search.") self.logger.info(f"Item '{base_repo_name}' not found via search."); return None
return None
for repo in search_result["data"]: for repo in search_result["data"]:
if repo.get("name").lower() == repo_name.lower(): if repo.get("name").lower() == base_repo_name.lower():
self.logger.info(f"Found exact match for repository '{repo_name}'.") self.logger.info(f"Found base repository '{base_repo_name}'.")
return { owner, clone_url = repo.get("owner", {}).get("login"), repo.get("clone_url")
"name": repo.get("name"), if is_wiki:
"owner": repo.get("owner", {}).get("login"), return {"name": repo_name, "owner": owner, "clone_url": (clone_url[:-4] + ".wiki.git" if clone_url and clone_url.endswith(".git") else None), "is_wiki": True}
"clone_url": repo.get("clone_url"), else:
"description": repo.get("description"), return {"name": repo.get("name"), "owner": owner, "clone_url": clone_url, "description": repo.get("description"), "private": repo.get("private"), "size_kb": repo.get("size", 0), "is_wiki": False}
"private": repo.get("private"), self.logger.info(f"No exact match for '{base_repo_name}' found in search results."); return None
"size_kb": repo.get("size", 0)
}
self.logger.info(f"No exact match for repository '{repo_name}' found in search results.")
return None
def create_repository(self, name: str, description: str = "", private: bool = True) -> Dict[str, Any]: def create_repository(self, name: str, description: str = "", private: bool = True, is_wiki: bool = False) -> Dict[str, Any]:
""" self.logger.info(f"Request to create item: '{name}' (is_wiki={is_wiki})")
Creates a new repository on the Gitea platform. base_repo_name = name.removesuffix(" (Wiki)").strip()
""" if is_wiki:
self.logger.info(f"Creating new repository '{name}'...") base_repo = self.get_repository(base_repo_name)
payload = {"name": name, "description": description, "private": private} if not base_repo: raise GiteaAPIError(f"Cannot create wiki for non-existent repository '{base_repo_name}'.")
new_repo = self._api_request("POST", "/user/repos", json_data=payload) self.enable_wiki(base_repo["owner"], base_repo_name)
return self.get_repository(name)
self.logger.info(f"Successfully created repository '{name}'.") else:
return { payload = {"name": name, "description": description, "private": private}
"name": new_repo.get("name"), new_repo = self._api_request("POST", "/user/repos", json_data=payload)
"owner": new_repo.get("owner", {}).get("login"), self.logger.info(f"Successfully created repository '{name}'.")
"clone_url": new_repo.get("clone_url"), return {"name": new_repo.get("name"), "owner": new_repo.get("owner", {}).get("login"), "clone_url": new_repo.get("clone_url"), "description": new_repo.get("description"), "private": new_repo.get("private"), "size_kb": new_repo.get("size", 0), "is_wiki": False}
"description": new_repo.get("description"),
"private": new_repo.get("private"),
"size_kb": new_repo.get("size", 0)
}
def get_repository_branches(self, owner: str, repo_name: str) -> Dict[str, str]: def get_repository_branches(self, owner: str, repo_name: str) -> Dict[str, str]:
"""
Retrieves a dictionary of branches and their head commit hashes.
"""
self.logger.info(f"Fetching branches for '{owner}/{repo_name}' from Gitea...") self.logger.info(f"Fetching branches for '{owner}/{repo_name}' from Gitea...")
endpoint = f"/repos/{owner}/{repo_name}/branches" api_repo_name = repo_name.removesuffix(" (Wiki)").strip()
if repo_name.endswith(" (Wiki)"): api_repo_name += ".wiki"
endpoint = f"/repos/{owner}/{api_repo_name}/branches"
try: try:
branches_data = self._api_request("GET", endpoint) branches_data = self._api_request("GET", endpoint, raise_on_404=False)
branches = {} if branches_data is None: return {}
if branches_data: return {b.get("name"): b.get("commit", {}).get("id") for b in branches_data if b.get("name") and b.get("commit", {}).get("id")}
for branch in branches_data:
branch_name = branch.get("name")
commit_hash = branch.get("commit", {}).get("id")
if branch_name and commit_hash:
branches[branch_name] = commit_hash
self.logger.debug(f"Found branches for '{repo_name}': {list(branches.keys())}")
return branches
except GiteaAPIError as e: except GiteaAPIError as e:
self.logger.error(f"Failed to fetch branches for '{repo_name}': {e}") self.logger.error(f"Failed to fetch branches for '{repo_name}': {e}"); return {}
return {}
def set_default_branch(self, owner: str, repo_name: str, branch_name: str): def set_default_branch(self, owner: str, repo_name: str, branch_name: str):
""" api_repo_name = repo_name.removesuffix(" (Wiki)").strip()
Sets the default branch for a repository via the API. endpoint = f"/repos/{owner}/{api_repo_name}"
""" payload = {"default_branch": branch_name} if not repo_name.endswith(" (Wiki)") else {"wiki": {"default_branch": branch_name}}
self.logger.info(f"Setting default branch for '{owner}/{repo_name}' to '{branch_name}'...")
endpoint = f"/repos/{owner}/{repo_name}"
payload = {
"default_branch": branch_name
}
try: try:
self._api_request("PATCH", endpoint, json_data=payload) self._api_request("PATCH", endpoint, json_data=payload)
self.logger.info("Default branch set successfully.") self.logger.info(f"Default branch for '{repo_name}' set to '{branch_name}'.")
except GiteaAPIError as e: except GiteaAPIError as e: self.logger.error(f"Failed to set default branch for '{repo_name}': {e}")
self.logger.error(f"Failed to set default branch for '{repo_name}': {e}")
# This is not a critical failure, so we log it but don't re-raise. def enable_wiki(self, owner: str, repo_name: str):
self.logger.info(f"Ensuring wiki is enabled for '{owner}/{repo_name}'...")
endpoint = f"/repos/{owner}/{repo_name}"
payload = {"has_wiki": True}
try:
self._api_request("PATCH", endpoint, json_data=payload)
self.logger.info(f"Wiki enabled successfully for '{owner}/{repo_name}'.")
except GiteaAPIError as e: self.logger.error(f"Failed to enable wiki for '{repo_name}': {e}"); raise

View File

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

View File

@ -23,6 +23,8 @@ from ..utility import logger as logger_util
def format_size(size_kb: int) -> str: def format_size(size_kb: int) -> str:
"""Formats size in kilobytes to a human-readable string (KB, MB, GB).""" """Formats size in kilobytes to a human-readable string (KB, MB, GB)."""
if not size_kb or size_kb <= 0:
return "N/A"
if size_kb < 1024: if size_kb < 1024:
return f"{size_kb} KB" return f"{size_kb} KB"
elif size_kb < 1024 * 1024: elif size_kb < 1024 * 1024:
@ -105,7 +107,7 @@ class BaseTab(ttk.Frame):
class ExportTab(BaseTab): class ExportTab(BaseTab):
"""Tab for fetching repos from a server and exporting them to bundles.""" """Tab for fetching repos and wikis from a server and exporting them."""
def __init__(self, parent, app_instance, **kwargs): def __init__(self, parent, app_instance, **kwargs):
super().__init__(parent, app_instance, "ExportTab", **kwargs) super().__init__(parent, app_instance, "ExportTab", **kwargs)
self.repositories = [] self.repositories = []
@ -121,23 +123,28 @@ class ExportTab(BaseTab):
top_frame.pack(side="top", fill="x") top_frame.pack(side="top", fill="x")
selector_frame = self.create_profile_selector(top_frame, "Export from Server:") selector_frame = self.create_profile_selector(top_frame, "Export from Server:")
selector_frame.pack(side="left") selector_frame.pack(side="left")
self.fetch_button = ttk.Button(top_frame, text="Fetch Repositories", command=self._start_repo_fetch) self.fetch_button = ttk.Button(top_frame, text="Fetch Items", command=self._start_repo_fetch)
self.fetch_button.pack(side="left", padx=10) self.fetch_button.pack(side="left", padx=10)
self.fetch_button.state(['disabled']) self.fetch_button.state(['disabled'])
self.export_button = ttk.Button(top_frame, text="Export Selected", command=self._start_export) 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.pack(side="left", padx=5)
self.export_button.state(['disabled']) self.export_button.state(['disabled'])
tree_frame = ttk.Frame(self) tree_frame = ttk.Frame(self)
tree_frame.pack(expand=True, fill="both", pady=5) tree_frame.pack(expand=True, fill="both", pady=5)
columns = ("name", "size", "description", "private")
columns = ("name", "type", "size", "description")
self.repo_tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="extended") 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.heading("name", text="Name"); self.repo_tree.heading("type", text="Type"); self.repo_tree.heading("size", text="Size (est.)"); self.repo_tree.heading("description", text="Description")
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") self.repo_tree.column("name", width=250, stretch=tk.NO); self.repo_tree.column("type", width=80, stretch=tk.NO, anchor="center"); self.repo_tree.column("size", width=100, stretch=tk.NO, anchor="e"); self.repo_tree.column("description", width=500)
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.repo_tree.yview) vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.repo_tree.yview)
self.repo_tree.configure(yscrollcommand=vsb.set) self.repo_tree.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y") vsb.pack(side="right", fill="y")
self.repo_tree.pack(side="left", expand=True, fill="both") self.repo_tree.pack(side="left", expand=True, fill="both")
self.repo_tree.bind("<<TreeviewSelect>>", self._update_selection_info) self.repo_tree.bind("<<TreeviewSelect>>", self._update_selection_info)
info_frame = ttk.LabelFrame(self, text="Selection Info", padding="5") info_frame = ttk.LabelFrame(self, text="Selection Info", padding="5")
info_frame.pack(side="bottom", fill="x", pady=(5, 0)) info_frame.pack(side="bottom", fill="x", pady=(5, 0))
selection_buttons_frame = ttk.Frame(info_frame) selection_buttons_frame = ttk.Frame(info_frame)
@ -168,7 +175,7 @@ class ExportTab(BaseTab):
def _start_repo_fetch(self): def _start_repo_fetch(self):
if not self.active_client: return if not self.active_client: return
self.logger.info(f"Fetching repository list from '{self.selected_profile_name.get()}'...") self.logger.info(f"Fetching items list from '{self.selected_profile_name.get()}'...")
self.fetch_button.state(['disabled']); self.export_button.state(['disabled']) self.fetch_button.state(['disabled']); self.export_button.state(['disabled'])
threading.Thread(target=self._load_repositories_thread, daemon=True).start() threading.Thread(target=self._load_repositories_thread, daemon=True).start()
@ -178,16 +185,24 @@ class ExportTab(BaseTab):
self.repo_data_map = {repo['name']: repo for repo in self.repositories} self.repo_data_map = {repo['name']: repo for repo in self.repositories}
self.app.root.after(10, self._update_repo_treeview) self.app.root.after(10, self._update_repo_treeview)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to fetch repositories: {e}", exc_info=True) self.logger.error(f"Failed to fetch items: {e}", exc_info=True)
self.app.root.after(10, lambda: messagebox.showerror("Fetch Error", f"Failed to fetch repositories:\n{e}")) self.app.root.after(10, lambda: messagebox.showerror("Fetch Error", f"Failed to fetch items:\n{e}"))
finally: finally:
self.app.root.after(10, lambda: self.fetch_button.state(['!disabled'])) self.app.root.after(10, lambda: self.fetch_button.state(['!disabled']))
def _update_repo_treeview(self): def _update_repo_treeview(self):
self.repo_tree.delete(*self.repo_tree.get_children()) self.repo_tree.delete(*self.repo_tree.get_children())
for repo in self.repositories: # Sort to show wikis directly after their parent repository
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"]) sorted_repos = sorted(self.repositories, key=lambda x: x['name'].replace(" (Wiki)", " (Wiki)Z"))
self.logger.info(f"Displayed {len(self.repositories)} repositories.") for repo in sorted_repos:
repo_type = "Wiki" if repo.get("is_wiki") else "Repository"
self.repo_tree.insert("", "end", values=(
repo["name"],
repo_type,
format_size(repo.get("size_kb", 0)),
repo.get("description", "")
), iid=repo["name"])
self.logger.info(f"Displayed {len(self.repositories)} items.")
self.export_button.state(['!disabled'] if self.repositories else ['disabled']) self.export_button.state(['!disabled'] if self.repositories else ['disabled'])
self._update_selection_info() self._update_selection_info()
@ -195,10 +210,10 @@ class ExportTab(BaseTab):
selected_iids = self.repo_tree.selection() selected_iids = self.repo_tree.selection()
if not selected_iids: return if not selected_iids: return
if not self.sync_bundle_path: return if not self.sync_bundle_path: return
repos_to_export = [repo for repo in self.repositories if repo["name"] in selected_iids] repos_to_export = [self.repo_data_map[iid] for iid in selected_iids if iid in self.repo_data_map]
sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager")) 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.logger.info(f"Starting export for {len(repos_to_export)} selected items...")
self.export_button.state(['disabled']); self.fetch_button.state(['disabled']) self.export_button.state(['disabled']); self.fetch_button.state(['disabled'])
self.total_export_size = 0 self.total_export_size = 0
self.app.status_bar_progress.config(value=0) self.app.status_bar_progress.config(value=0)
@ -229,12 +244,12 @@ class ExportTab(BaseTab):
class ImportTab(BaseTab): class ImportTab(BaseTab):
# This class remains unchanged functionally
def __init__(self, parent, app_instance, **kwargs): def __init__(self, parent, app_instance, **kwargs):
super().__init__(parent, app_instance, "ImportTab", **kwargs) super().__init__(parent, app_instance, "ImportTab", **kwargs)
self.comparison_results = [] self.comparison_results = []
self._create_widgets() self._create_widgets()
self.update_profile_list() self.update_profile_list()
def _create_widgets(self): def _create_widgets(self):
top_frame = ttk.Frame(self, padding="5"); top_frame.pack(side="top", fill="x") 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") selector_frame = self.create_profile_selector(top_frame, "Import to Server:"); selector_frame.pack(side="left")
@ -246,57 +261,39 @@ class ImportTab(BaseTab):
self.bundle_tree.column("name", width=300); self.bundle_tree.column("status", width=200, anchor="center") self.bundle_tree.column("name", width=300); self.bundle_tree.column("status", width=200, anchor="center")
for state in SyncState: self.bundle_tree.tag_configure(state.name, background=self._get_color_for_state(state)) for state in SyncState: self.bundle_tree.tag_configure(state.name, background=self._get_color_for_state(state))
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.bundle_tree.yview); self.bundle_tree.configure(yscrollcommand=vsb.set); vsb.pack(side="right", fill="y"); self.bundle_tree.pack(side="left", expand=True, fill="both") vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.bundle_tree.yview); self.bundle_tree.configure(yscrollcommand=vsb.set); vsb.pack(side="right", fill="y"); self.bundle_tree.pack(side="left", expand=True, fill="both")
def _get_color_for_state(self, state): return {SyncState.AHEAD: "lightblue", SyncState.NEW_REPO: "lightgreen", SyncState.BEHIND: "khaki", SyncState.DIVERGED: "salmon", SyncState.ERROR: "lightgrey", SyncState.IDENTICAL: "white"}.get(state, "white") def _get_color_for_state(self, state): return {SyncState.AHEAD: "lightblue", SyncState.NEW_REPO: "lightgreen", SyncState.BEHIND: "khaki", SyncState.DIVERGED: "salmon", SyncState.ERROR: "lightgrey", SyncState.IDENTICAL: "white"}.get(state, "white")
def _on_profile_select(self, event=None): super()._on_profile_select(event); self.check_status_button.state(['!disabled'] if self.active_client else ['disabled']); self.import_button.state(['disabled']); self.bundle_tree.delete(*self.bundle_tree.get_children()) def _on_profile_select(self, event=None): super()._on_profile_select(event); self.check_status_button.state(['!disabled'] if self.active_client else ['disabled']); self.import_button.state(['disabled']); self.bundle_tree.delete(*self.bundle_tree.get_children())
def _start_bundle_check(self): def _start_bundle_check(self):
if not self.active_client or not self.sync_bundle_path: return 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")) 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.logger.info("Checking bundle status..."); self.check_status_button.state(['disabled'])
threading.Thread(target=self._check_status_thread, args=(sync_manager,), daemon=True).start() threading.Thread(target=self._check_status_thread, args=(sync_manager,), daemon=True).start()
def _check_status_thread(self, sync_manager): def _check_status_thread(self, sync_manager):
try: try:
self.comparison_results = sync_manager.compare_bundles_with_remote() self.comparison_results = sync_manager.compare_bundles_with_remote()
self.app.root.after(10, self._update_bundle_treeview) self.app.root.after(10, self._update_bundle_treeview)
except Exception as e: except Exception as e: self.logger.error(f"Failed to compare bundle status: {e}", exc_info=True); self.app.root.after(10, lambda: messagebox.showerror("Comparison Error", f"Failed to compare bundle status:\n{e}"))
self.logger.error(f"Failed to compare bundle status: {e}", exc_info=True) finally: self.app.root.after(10, lambda: self.check_status_button.state(['!disabled']))
self.app.root.after(10, lambda: messagebox.showerror("Comparison Error", f"Failed to compare bundle status:\n{e}"))
finally:
self.app.root.after(10, lambda: self.check_status_button.state(['!disabled']))
def _update_bundle_treeview(self): def _update_bundle_treeview(self):
self.bundle_tree.delete(*self.bundle_tree.get_children()) self.bundle_tree.delete(*self.bundle_tree.get_children())
for result in self.comparison_results: for result in self.comparison_results: self.bundle_tree.insert("", "end", values=(result["name"], result["state"].name), tags=(result["state"].name,), iid=result["name"])
self.bundle_tree.insert("", "end", values=(result["name"], result["state"].name), tags=(result["state"].name,), iid=result["name"])
self.import_button.state(['!disabled'] if self.comparison_results else ['disabled']) self.import_button.state(['!disabled'] if self.comparison_results else ['disabled'])
def _start_import(self): def _start_import(self):
selected_iids = self.bundle_tree.selection() selected_iids = self.bundle_tree.selection()
if not selected_iids: return 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]] 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: if not repos_to_import: messagebox.showwarning("Invalid Selection", "Please select repositories that are 'AHEAD' or 'NEW_REPO' to import."); return
messagebox.showwarning("Invalid Selection", "Please select repositories that are 'AHEAD' or 'NEW_REPO' to import.")
return
sync_manager = SyncManager(self.app.git_manager, self.active_client, self.sync_bundle_path, logging.getLogger("SyncManager")) 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.logger.info(f"Starting import for {len(repos_to_import)} repositories...")
self.import_button.state(['disabled']); self.check_status_button.state(['disabled']) self.import_button.state(['disabled']); self.check_status_button.state(['disabled'])
# --- MODIFICATION START ---
# Using a lambda in the target to ensure the arguments are passed correctly and clearly.
thread_target = lambda: self._import_thread(sync_manager, repos_to_import) thread_target = lambda: self._import_thread(sync_manager, repos_to_import)
threading.Thread(target=thread_target, daemon=True).start() threading.Thread(target=thread_target, daemon=True).start()
# --- MODIFICATION END ---
def _import_thread(self, sync_manager, repos_to_import): def _import_thread(self, sync_manager, repos_to_import):
try: try:
sync_manager.import_repositories_from_bundles(repos_to_import) sync_manager.import_repositories_from_bundles(repos_to_import)
self.app.root.after(0, lambda: messagebox.showinfo("Success", "Import process completed.")) self.app.root.after(0, lambda: messagebox.showinfo("Success", "Import process completed."))
except Exception as e: except Exception as e:
self.logger.error(f"Import failed: {e}", exc_info=True) self.logger.error(f"Import failed: {e}", exc_info=True)
# Pass the actual exception to the messagebox for a more informative error.
self.app.root.after(0, lambda: messagebox.showerror("Import Error", f"An error occurred: {e}")) self.app.root.after(0, lambda: messagebox.showerror("Import Error", f"An error occurred: {e}"))
finally: finally:
self.app.root.after(0, lambda: self.import_button.state(['!disabled'])) self.app.root.after(0, lambda: self.import_button.state(['!disabled']))