diff --git a/GitUtility.py b/GitUtility.py index f9acdef..dd331ff 100644 --- a/GitUtility.py +++ b/GitUtility.py @@ -165,6 +165,7 @@ class GitSvnSyncApp: config_manager_instance=self.config_manager, profile_sections_list=self.config_manager.get_profile_sections(), refresh_remote_status_cb=self.refresh_remote_status, + clone_remote_repo_cb=self.clone_remote_repo, ) print("MainFrame GUI created.") log_handler.log_debug( @@ -2375,6 +2376,102 @@ class GitSvnSyncApp: "remote_name": remote_name, # Passa nome remoto per messaggi }, ) + + def clone_remote_repo(self): + """Handles the 'Clone from Remote...' action: shows dialog, validates, starts worker.""" + func_name = "clone_remote_repo" + log_handler.log_info(f"--- Action Triggered: Clone Remote Repository ---", func_name=func_name) + + if not hasattr(self, "main_frame") or not self.main_frame.winfo_exists(): + log_handler.log_error("Cannot start clone: Main frame not available.", func_name=func_name) + return + + # Mostra il dialogo modale per ottenere URL, directory padre e nome profilo + dialog = CloneFromRemoteDialog(self.master) + # dialog.result conterrà None se premuto Cancel, o (url, parent_dir, profile_name) se OK + dialog_result = dialog.result + + if not dialog_result: + log_handler.log_info("Clone operation cancelled by user in dialog.", func_name=func_name) + self.main_frame.update_status_bar("Clone cancelled.") + return + + # Estrai i dati dal risultato del dialogo + remote_url, local_parent_dir, profile_name_input = dialog_result + + # --- Logica per derivare nomi e validare percorso finale --- + final_profile_name = "" + target_clone_dir = "" + try: + # Deriva il nome della directory del repository dall'URL + repo_name_from_url = os.path.basename(remote_url) + if repo_name_from_url.endswith(".git"): + repo_name_from_url = repo_name_from_url[:-4] + if not repo_name_from_url: # Se l'URL termina con / o è strano + raise ValueError("Could not derive repository name from URL.") + + # Costruisci il percorso completo dove verrà clonato il repository + target_clone_dir = os.path.join(local_parent_dir, repo_name_from_url) + target_clone_dir = os.path.abspath(target_clone_dir) # Normalizza il percorso + + # Determina il nome finale del profilo + if profile_name_input: + final_profile_name = profile_name_input + # Validazione aggiuntiva: assicurati che il nome profilo non esista già + if final_profile_name in self.config_manager.get_profile_sections(): + raise ValueError(f"Profile name '{final_profile_name}' already exists. Please choose a different name.") + else: + # Usa il nome derivato dall'URL come nome profilo, verificando non esista + final_profile_name = repo_name_from_url + counter = 1 + while final_profile_name in self.config_manager.get_profile_sections(): + final_profile_name = f"{repo_name_from_url}_{counter}" + counter += 1 + log_handler.log_debug(f"Derived target clone directory: {target_clone_dir}", func_name=func_name) + log_handler.log_debug(f"Determined profile name: {final_profile_name}", func_name=func_name) + + # --- CONTROLLO FONDAMENTALE: La directory di destinazione esiste già? --- + if os.path.exists(target_clone_dir): + # Non clonare se la directory esiste (git clone fallirebbe comunque) + error_msg = f"Clone failed: Target directory already exists:\n{target_clone_dir}\nPlease choose a different parent directory or ensure the target is clear." + log_handler.log_error(error_msg, func_name=func_name) + self.main_frame.show_error("Clone Path Error", error_msg) + self.main_frame.update_status_bar("Clone failed: Target directory exists.") + return # Interrompe l'operazione + + except ValueError as ve: + # Errore nella derivazione nomi o validazione profilo + log_handler.log_error(f"Clone configuration error: {ve}", func_name=func_name) + self.main_frame.show_error("Configuration Error", str(ve)) + self.main_frame.update_status_bar("Clone failed: Configuration error.") + return + except Exception as e: + # Errore imprevisto durante la preparazione + log_handler.log_exception(f"Unexpected error preparing for clone: {e}", func_name=func_name) + self.main_frame.show_error("Internal Error", f"An unexpected error occurred:\n{e}") + self.main_frame.update_status_bar("Clone failed: Internal error.") + return + + # --- Avvia Worker Asincrono --- + log_handler.log_info(f"Starting clone for '{remote_url}' into '{target_clone_dir}'...", func_name=func_name) + + # Argomenti per il worker: dipendenza + parametri + args = (self.git_commands, remote_url, target_clone_dir, final_profile_name) + self._start_async_operation( + async_workers.run_clone_remote_async, # Worker esterno per clone + args, + { + "context": "clone_remote", # Contesto per il risultato + "status_msg": f"Cloning '{repo_name_from_url}'...", # Usa nome repo per status + # Passiamo i dati necessari per la creazione del profilo nel contesto, + # così _check_completion_queue può accedervi facilmente in caso di successo. + "clone_success_data": { + 'profile_name': final_profile_name, + 'cloned_path': target_clone_dir, + 'remote_url': remote_url + } + }, + ) def refresh_remote_status(self): """Starts the async check for ahead/behind status.""" @@ -2487,7 +2584,10 @@ class GitSvnSyncApp: elif task_context == "interactive_auth" and status_from_result == 'success': should_reenable_now = False log_handler.log_debug("Delaying widget re-enable: re-checking connection after interactive auth.", func_name=func_name) - # Non ritardare per errore get_ahead_behind, l'utente può riprovare manualmente + elif task_context == 'clone_remote' and status_from_result == 'success': + # Non riabilitare dopo clone successo, il caricamento profilo gestirà lo stato + should_reenable_now = False + log_handler.log_debug("Delaying widget re-enable: profile load will handle state after clone.", func_name=func_name) # Riabilita i widget se non è necessario attendere if should_reenable_now: @@ -2539,11 +2639,13 @@ class GitSvnSyncApp: # Aggiorna la status bar (usa la funzione helper della GUI) if hasattr(self, "main_frame") and self.main_frame.winfo_exists(): - self.main_frame.update_status_bar(message, bg_color=status_color, duration_ms=reset_duration) + # Non aggiornare la status bar immediatamente dopo un clone successo, + # il caricamento del profilo lo farà. + if not (task_context == 'clone_remote' and status == 'success'): + self.main_frame.update_status_bar(message, bg_color=status_color, duration_ms=reset_duration) # --- Processa risultato specifico per task --- # Ottieni path corrente per eventuali refresh - # Usiamo una variabile separata perché il path per i refresh potrebbe differire da repo_path_conflict repo_path_for_refreshes = self._get_and_validate_svn_path("Post-Action Refresh Check") # Lista per raccogliere funzioni di refresh da chiamare alla fine refresh_list = [] @@ -2557,8 +2659,7 @@ class GitSvnSyncApp: auth_status = 'ok' log_handler.log_info(f"Connection check successful for '{remote_name}'.", func_name=func_name) self._update_gui_auth_status(auth_status) - # Dopo un check OK, aggiorna anche lo stato ahead/behind - post_action_sync_refresh_needed = True + post_action_sync_refresh_needed = True # Aggiorna stato A/B dopo check OK elif status == 'auth_required': log_handler.log_warning(f"Authentication required for remote '{remote_name}'.", func_name=func_name) self._update_gui_auth_status('required') @@ -2576,14 +2677,15 @@ class GitSvnSyncApp: args_interactive, { "context": "interactive_auth", "status_msg": f"Attempting interactive auth for '{remote_name}'", "original_context": context } ) + # Non riabilitare widget qui else: log_handler.log_info("User declined interactive authentication attempt.", func_name=func_name) if hasattr(self, "main_frame") and self.main_frame.winfo_exists(): self.main_frame.set_action_widgets_state(tk.NORMAL) elif status == 'error': error_type = result_value if result_value in ['connection_failed', 'unknown_error', 'worker_exception'] else 'unknown_error' - self._update_gui_auth_status(error_type) # Aggiorna stato auth - if hasattr(self.main_frame, "update_ahead_behind_status"): self.main_frame.update_ahead_behind_status(status_text="Sync Status: Error") # Aggiorna stato sync + self._update_gui_auth_status(error_type) + if hasattr(self.main_frame, "update_ahead_behind_status"): self.main_frame.update_ahead_behind_status(status_text="Sync Status: Error") if hasattr(self, "main_frame"): self.main_frame.show_error("Connection Error", f"{message}") elif task_context == "interactive_auth": @@ -2610,9 +2712,7 @@ class GitSvnSyncApp: f"Please resolve the conflicts manually in:\n{repo_path_conflict}\n\n" f"After resolving, stage the changes and commit them." ) - # Aggiorna solo la lista dei file modificati if self.refresh_changed_files_list not in refresh_list: refresh_list.append(self.refresh_changed_files_list) - # Resetta stato sync a unknown/error dopo conflitto if hasattr(self.main_frame, "update_ahead_behind_status"): self.main_frame.update_ahead_behind_status(status_text="Sync Status: Conflict") # --- Gestione specifica per PUSH REJECTED --- @@ -2620,8 +2720,7 @@ class GitSvnSyncApp: log_handler.log_error(f"Push rejected for branch '{rejected_branch}'. User needs to pull.", func_name=func_name) if hasattr(self, "main_frame"): self.main_frame.show_warning("Push Rejected", f"{message}") - # Dopo un push rifiutato, suggerisci fetch e aggiorna stato sync - if self.fetch_remote not in refresh_list: refresh_list.append(self.fetch_remote) # Fetch aggiornerà lo stato sync indirettamente + if self.fetch_remote not in refresh_list: refresh_list.append(self.fetch_remote) # Fetch aggiornerà stato sync # --- Gestione specifica per GET_AHEAD_BEHIND --- elif task_context == 'get_ahead_behind': @@ -2629,23 +2728,80 @@ class GitSvnSyncApp: if status == 'success': ahead, behind = result_value if isinstance(result_value, tuple) else (None, None) log_handler.log_info(f"Ahead/Behind status updated for '{local_branch_ctx}': Ahead={ahead}, Behind={behind}", func_name=func_name) - log_handler.log_debug(f"Calling update_ahead_behind_status with: branch='{local_branch_ctx}', ahead={ahead}, behind={behind}", func_name=func_name) if hasattr(self.main_frame, "update_ahead_behind_status"): self.main_frame.update_ahead_behind_status(current_branch=local_branch_ctx, ahead=ahead, behind=behind) elif status == 'error': log_handler.log_error(f"Failed to get ahead/behind status for '{local_branch_ctx}': {message}", func_name=func_name) - log_handler.log_debug(f"Calling update_ahead_behind_status with: branch='{local_branch_ctx}', status_text='Sync Status: Error'", func_name=func_name) if hasattr(self.main_frame, "update_ahead_behind_status"): self.main_frame.update_ahead_behind_status(current_branch=local_branch_ctx, status_text=f"Sync Status: Error") + # --- Gestione specifica per CLONE_REMOTE --- + elif task_context == 'clone_remote': + if status == 'success': + log_handler.log_info(f"Clone successful. Creating profile...", func_name=func_name) + success_data = context.get('clone_success_data') or result_value + if success_data and isinstance(success_data, dict): + new_profile_name = success_data.get('profile_name') + cloned_repo_path = success_data.get('cloned_path') + cloned_remote_url = success_data.get('remote_url') + + if new_profile_name and cloned_repo_path and cloned_remote_url: + try: + defaults = self.config_manager._get_expected_keys_with_defaults() + defaults['svn_working_copy_path'] = cloned_repo_path + defaults['remote_url'] = cloned_remote_url + defaults['remote_name'] = DEFAULT_REMOTE_NAME + defaults['bundle_name'] = f"{new_profile_name}.bundle" + defaults['bundle_name_updated'] = f"{new_profile_name}_update.bundle" + defaults['autobackup'] = "False"; defaults['autocommit'] = "False" + defaults['commit_message'] = "Initial commit check" + + self.config_manager.add_section(new_profile_name) + for key, value in defaults.items(): + self.config_manager.set_profile_option(new_profile_name, key, value) + self.config_manager.save_config() + log_handler.log_info(f"Profile '{new_profile_name}' created successfully for cloned repo.", func_name=func_name) + + # Aggiorna GUI e seleziona nuovo profilo (triggera load) + sections = self.config_manager.get_profile_sections() + if hasattr(self, "main_frame"): + self.main_frame.update_profile_dropdown(sections) + self.main_frame.profile_var.set(new_profile_name) + # Non aggiorniamo status bar qui, load_profile_settings lo farà + + except Exception as profile_e: + log_handler.log_exception(f"Clone successful, but failed to create profile '{new_profile_name}': {profile_e}", func_name=func_name) + if hasattr(self, "main_frame"): + self.main_frame.show_error("Profile Creation Error", f"Repository cloned, but failed to save profile '{new_profile_name}'.\nPlease add it manually.") + self.main_frame.update_status_bar("Clone successful, but profile creation failed.") + # Riabilita widget se la creazione profilo fallisce + if hasattr(self, "main_frame") and self.main_frame.winfo_exists(): + self.main_frame.set_action_widgets_state(tk.NORMAL) + + else: + log_handler.log_error("Clone successful, but missing data to create profile.", func_name=func_name) + if hasattr(self, "main_frame"): self.main_frame.update_status_bar("Clone successful, but failed to retrieve data for profile creation.") + if hasattr(self, "main_frame") and self.main_frame.winfo_exists(): self.main_frame.set_action_widgets_state(tk.NORMAL) + else: + log_handler.log_error("Clone successful, but success data is missing or invalid in result.", func_name=func_name) + if hasattr(self, "main_frame"): self.main_frame.update_status_bar("Clone successful, but internal data error occurred.") + if hasattr(self, "main_frame") and self.main_frame.winfo_exists(): self.main_frame.set_action_widgets_state(tk.NORMAL) + + elif status == 'error': + # Clone fallito + log_handler.log_error(f"Clone operation failed: {message}", func_name=func_name) + if hasattr(self, "main_frame"): self.main_frame.show_error("Clone Error", f"{message}") + # Widget già riabilitati all'inizio + # --- Gestione risultati altri task (successo) --- elif status == 'success': # Determina quali refresh avviare e se aggiornare lo stato sync if task_context in ['prepare_repo', 'fetch_bundle', 'commit', 'create_tag', 'checkout_tag', 'create_branch', 'checkout_branch', '_handle_gitignore_save', 'add_file', 'apply_remote_config', - 'fetch_remote', 'pull_remote', - 'push_remote', 'push_tags_remote']: + 'fetch_remote', 'pull_remote', # Pull non-conflict + 'push_remote', 'push_tags_remote', # Push non-rejected + 'refresh_branches']: # Refresh branches richiede aggiornamento stato sync # Logica per popolare refresh_list if task_context == 'push_remote': if self.refresh_commit_history not in refresh_list: refresh_list.append(self.refresh_commit_history) @@ -2654,34 +2810,34 @@ class GitSvnSyncApp: elif task_context == 'push_tags_remote': if self.refresh_tag_list not in refresh_list: refresh_list.append(self.refresh_tag_list) post_action_sync_refresh_needed = True - elif task_context == 'pull_remote': # Pull successo (non conflitto) + elif task_context == 'pull_remote': if self.refresh_commit_history not in refresh_list: refresh_list.append(self.refresh_commit_history) if self.refresh_branch_list not in refresh_list: refresh_list.append(self.refresh_branch_list) if self.refresh_tag_list not in refresh_list: refresh_list.append(self.refresh_tag_list) if self.refresh_changed_files_list not in refresh_list: refresh_list.append(self.refresh_changed_files_list) post_action_sync_refresh_needed = True - elif task_context == 'fetch_remote': # Fetch successo + elif task_context == 'fetch_remote': if self.refresh_commit_history not in refresh_list: refresh_list.append(self.refresh_commit_history) if self.refresh_branch_list not in refresh_list: refresh_list.append(self.refresh_branch_list) if self.refresh_tag_list not in refresh_list: refresh_list.append(self.refresh_tag_list) post_action_sync_refresh_needed = True - elif task_context == 'apply_remote_config': # Apply Config successo - refresh_list.append(self.check_connection_auth) # Controlla connessione dopo apply + elif task_context == 'apply_remote_config': + refresh_list.append(self.check_connection_auth) if self.refresh_commit_history not in refresh_list: refresh_list.append(self.refresh_commit_history) if self.refresh_branch_list not in refresh_list: refresh_list.append(self.refresh_branch_list) post_action_sync_refresh_needed = True - elif task_context == 'checkout_branch' or task_context == 'checkout_tag': # Cambio branch/stato + elif task_context == 'checkout_branch' or task_context == 'checkout_tag': post_action_sync_refresh_needed = True - # Aggiungi refresh standard dopo checkout if self.refresh_commit_history not in refresh_list: refresh_list.append(self.refresh_commit_history) if self.refresh_branch_list not in refresh_list: refresh_list.append(self.refresh_branch_list) if self.refresh_tag_list not in refresh_list: refresh_list.append(self.refresh_tag_list) if self.refresh_changed_files_list not in refresh_list: refresh_list.append(self.refresh_changed_files_list) - elif task_context == 'create_branch' and not new_branch_context: # Creazione senza checkout - post_action_sync_refresh_needed = True # Aggiorna stato sync (sarà no upstream) + elif task_context == 'create_branch' and not new_branch_context: + post_action_sync_refresh_needed = True if self.refresh_commit_history not in refresh_list: refresh_list.append(self.refresh_commit_history) if self.refresh_branch_list not in refresh_list: refresh_list.append(self.refresh_branch_list) - + elif task_context == 'refresh_branches': # Caso specifico refresh branches + post_action_sync_refresh_needed = True # Serve aggiornare lo stato sync # Logica refresh per le altre azioni locali else: if committed or task_context in ['fetch_bundle','prepare_repo','create_tag','_handle_gitignore_save']: @@ -2693,16 +2849,15 @@ class GitSvnSyncApp: if task_context not in ['refresh_branches', 'checkout_branch']: if self.refresh_branch_list not in refresh_list: refresh_list.append(self.refresh_branch_list) - # --- Aggiornamenti diretti GUI (per i task di refresh stessi) --- elif task_context == 'refresh_tags': if hasattr(self, "main_frame"): self.main_frame.update_tag_list(result_value if isinstance(result_value, list) else []) elif task_context == 'refresh_branches': + # Già gestito sopra per triggerare post_action_sync_refresh_needed branches, current = result_value if isinstance(result_value, tuple) and len(result_value) == 2 else ([], None) if hasattr(self, "main_frame"): self.main_frame.update_branch_list(branches, current) self.main_frame.update_history_branch_filter(branches) - post_action_sync_refresh_needed = True # Aggiorna stato sync dopo refresh branch elif task_context == 'refresh_history': if hasattr(self, "main_frame"): self.main_frame.update_history_display(result_value if isinstance(result_value, list) else []) elif task_context == 'refresh_changes': @@ -2713,7 +2868,6 @@ class GitSvnSyncApp: if hasattr(self, "main_frame"): self.main_frame.clear_commit_message() if task_context == 'create_branch' and new_branch_context: if hasattr(self, "main_frame") and self.main_frame.ask_yes_no("Checkout?", f"Switch to new branch '{new_branch_context}'?"): - # Avvia checkout asincrono (che triggererà i suoi refresh) self.checkout_branch(branch_to_checkout=new_branch_context, repo_path_override=repo_path_for_refreshes) post_action_sync_refresh_needed = False # Verrà fatto dopo il checkout # Se non fa checkout, i refresh sono già in lista e post_action_sync_refresh_needed è True @@ -2728,11 +2882,11 @@ class GitSvnSyncApp: post_action_sync_refresh_needed = True elif status == 'error': - # Gestione errori generica (esclusi check_connection, interactive_auth, pull_conflict, push_rejected, get_ahead_behind) + # Gestione errori generica (esclusi contesti speciali gestiti sopra) log_handler.log_error(f"Error reported for task '{task_context}': {message}", func_name=func_name) error_details = f"{message}\n({type(exception).__name__}: {exception})" if exception else message - # Gestione errore per fetch_remote, pull (non conflitto), push (non rifiuto), push_tags + # Gestione errore per fetch_remote, pull (non conflitto), push (non rifiuto), push_tags, apply_config if task_context in ['fetch_remote', 'pull_remote', 'push_remote', 'push_tags_remote', 'apply_remote_config']: auth_related_error = False; conn_related_error = False if isinstance(exception, GitCommandError) and exception.stderr: stderr_low = exception.stderr.lower(); @@ -2741,10 +2895,8 @@ class GitSvnSyncApp: if auth_related_error: self._update_gui_auth_status('failed') elif conn_related_error: self._update_gui_auth_status('connection_failed') else: self._update_gui_auth_status('unknown_error') - # Mostra popup specifico del task action_name = task_context.replace("_remote", "").replace("_", " ").title() if hasattr(self, "main_frame"): self.main_frame.show_error(f"{action_name} Error", f"{message}") - # Resetta stato sync su errore if hasattr(self.main_frame, "update_ahead_behind_status"): self.main_frame.update_ahead_behind_status(status_text="Sync Status: Error") # Gestione errori per altri task @@ -2770,28 +2922,32 @@ class GitSvnSyncApp: if hasattr(self, "main_frame"): self.main_frame.update_changed_files_list(["(Error refreshing changes)"]) # Non serve aggiornare stato auth/sync per errori locali generici - delay_ms = 50 # Inizializza il ritardo base qui - # --- Trigger finale dei refresh asincroni raccolti (spostato dopo il blocco if/elif/else principale) --- + # --- Trigger finale dei refresh asincroni raccolti --- + # (Spostato dopo tutta la logica if/elif/else sullo stato) if repo_path_for_refreshes and refresh_list: log_handler.log_debug(f"Triggering {len(refresh_list)} async refreshes after '{task_context}'", func_name=func_name) - # Usa 'after' per separare leggermente l'avvio dei refresh dal ciclo corrente - delay_ms = 50 + current_delay = 50 # Ritardo base for refresh_func in refresh_list: try: - self.master.after(delay_ms, refresh_func) - delay_ms += 20 # Scaletta leggermente i refresh + self.master.after(current_delay, refresh_func) + current_delay += 50 # Scaletta leggermente i refresh except Exception as ref_e: log_handler.log_error(f"Error scheduling {getattr(refresh_func, '__name__', 'refresh function')}: {ref_e}", func_name=func_name) + # Usa l'ultimo delay per il refresh dello stato sync + delay_ms = current_delay elif refresh_list: log_handler.log_warning("Cannot trigger post-action UI refreshes: Repo path unavailable.", func_name=func_name) + delay_ms = 50 # Resetta delay se non ci sono refresh standard + else: + delay_ms = 50 # Resetta delay se non ci sono refresh standard # Triggera refresh stato ahead/behind SE necessario e non già in refresh_list if post_action_sync_refresh_needed and self.refresh_remote_status not in refresh_list: - current_repo_path = self._get_and_validate_svn_path("Post-Action Sync Status Check") - if current_repo_path: + current_repo_path_sync = self._get_and_validate_svn_path("Post-Action Sync Status Check") + if current_repo_path_sync: log_handler.log_debug(f"Triggering remote sync status refresh after '{task_context}'.", func_name=func_name) - self.master.after(delay_ms + 50, self.refresh_remote_status) # Dopo gli altri refresh + self.master.after(delay_ms + 50, self.refresh_remote_status) # Aggiunge un ulteriore piccolo delay # Log finale solo se non è stata gestita una ricorsione/nuovo avvio diff --git a/async_workers.py b/async_workers.py index 2901713..77ab7da 100644 --- a/async_workers.py +++ b/async_workers.py @@ -1292,6 +1292,98 @@ def run_get_ahead_behind_async( log_handler.log_debug( f"[Worker] Finished: Get Ahead/Behind for '{local_branch}'", func_name=func_name ) + +def run_clone_remote_async( + git_commands: GitCommands, # Dipendenza per eseguire clone + remote_url: str, + local_clone_path: str, # Path completo dove clonare + profile_name_to_create: str, # Nome del profilo da creare post-clone + results_queue: queue.Queue + ): + """ + Worker function to execute 'git clone' asynchronously. + Executed in a separate thread. + + Args: + git_commands (GitCommands): Instance to execute git commands. + remote_url (str): URL of the repository to clone. + local_clone_path (str): Full path to the target directory for the clone. + profile_name_to_create (str): The name for the new profile upon success. + results_queue (queue.Queue): Queue to put the result dictionary. + """ + func_name = "run_clone_remote_async" + log_handler.log_debug(f"[Worker] Started: Clone from '{remote_url}' into '{local_clone_path}'", func_name=func_name) + + result_info = {'status': 'unknown', 'message': 'Clone not completed.'} # Default + + try: + # Il controllo sull'esistenza della directory di destinazione + # è stato fatto PRIMA di avviare questo worker (in GitUtility.py). + # Qui eseguiamo direttamente il comando. + + # Chiama il metodo git_clone (che ha check=False) + clone_result = git_commands.git_clone(remote_url, local_clone_path) + + # Analizza il risultato del comando clone + if clone_result.returncode == 0: + # Successo + result_info['status'] = 'success' + result_info['message'] = f"Repository cloned successfully into '{os.path.basename(local_clone_path)}'." + # Passa i dati necessari per creare il profilo nel risultato + result_info['result'] = { + 'cloned_path': local_clone_path, + 'profile_name': profile_name_to_create, + 'remote_url': remote_url + } + log_handler.log_info(f"[Worker] Clone successful: {result_info['message']}", func_name=func_name) + + else: + # Errore durante il clone + result_info['status'] = 'error' + stderr_full = clone_result.stderr if clone_result.stderr else "" + stderr_lower = stderr_full.lower() + log_handler.log_error(f"Clone command failed (RC={clone_result.returncode}). Stderr: {stderr_lower}", func_name=func_name) + + # Controlla errori specifici noti + auth_errors = ["authentication failed", "permission denied", "could not read username", "fatal: could not read password"] + connection_errors = ["repository not found", "could not resolve host", "name or service not known", "network is unreachable"] + path_errors = ["already exists and is not an empty directory", "could not create work tree"] # Anche se controllato prima, può succedere + + if any(err in stderr_lower for err in auth_errors): + result_info['message'] = f"Authentication required or failed for cloning '{remote_url}'." + result_info['exception'] = GitCommandError(result_info['message'], stderr=clone_result.stderr) + # Potremmo impostare uno stato specifico 'auth_required' se vogliamo distinguerlo + # result_info['result'] = 'authentication needed' # Opzionale + elif any(err in stderr_lower for err in connection_errors): + result_info['message'] = f"Failed to connect while cloning: Repository or host '{remote_url}' not found/reachable." + result_info['exception'] = GitCommandError(result_info['message'], stderr=clone_result.stderr) + # result_info['result'] = 'connection_failed' # Opzionale + elif any(err in stderr_lower for err in path_errors): + result_info['message'] = f"Clone failed: Target directory '{local_clone_path}' already exists and is not empty, or could not be created." + result_info['exception'] = GitCommandError(result_info['message'], stderr=clone_result.stderr) + # result_info['result'] = 'path_error' # Opzionale + else: + # Errore generico di Git + result_info['message'] = f"Clone from '{remote_url}' failed (RC={clone_result.returncode}). Check logs." + result_info['exception'] = GitCommandError(result_info['message'], stderr=clone_result.stderr) + # result_info['result'] = 'unknown_error' # Opzionale + + # Metti il risultato in coda + results_queue.put(result_info) + + except Exception as e: + # Cattura eccezioni impreviste nel worker stesso + log_handler.log_exception(f"[Worker] UNEXPECTED EXCEPTION during clone operation: {e}", func_name=func_name) + results_queue.put( + { + "status": "error", + "exception": e, + "message": f"Unexpected error during clone operation: {type(e).__name__}", + "result": "worker_exception", # Stato specifico per errore worker + } + ) + finally: + log_handler.log_debug(f"[Worker] Finished: Clone Remote '{remote_url}'", func_name=func_name) # --- END OF FILE async_workers.py --- diff --git a/git_commands.py b/git_commands.py index 8ab6b2e..7f4af7c 100644 --- a/git_commands.py +++ b/git_commands.py @@ -1724,6 +1724,49 @@ class GitCommands: except Exception as e: log_handler.log_exception(f"Unexpected error getting ahead/behind count: {e}", func_name=func_name) return None, None # Segnala fallimento generico # Segnala fallimento # Segnala fallimento + + def git_clone(self, remote_url: str, local_directory_path: str) -> subprocess.CompletedProcess: + """ + Executes 'git clone --progress '. + Captures output (including progress) and hides console by default. + Does NOT raise exception on non-zero exit code (check=False). + + Args: + remote_url (str): The URL of the remote repository to clone. + local_directory_path (str): The full path to the new local directory + where the repository will be cloned. + + Returns: + subprocess.CompletedProcess: The result of the command execution. + """ + func_name = "git_clone" + log_handler.log_info(f"Cloning repository from '{remote_url}' into '{local_directory_path}'", func_name=func_name) + + # Comando: git clone --progress + # --progress forza l'output dello stato anche se stderr non è un terminale, + # utile per il logging. + cmd = ["git", "clone", "--progress", remote_url, local_directory_path] + + # Esegui catturando output, nascondendo console, ma NON sollevare eccezione (check=False) + # Il worker analizzerà il risultato per errori specifici (auth, path, etc.) + # Usiamo un timeout più lungo per clone, che può richiedere tempo + clone_timeout = 300 # 5 minuti, da aggiustare se necessario + + # Nota: Eseguiamo il clone nella directory *corrente* del processo principale + # perché la directory di destinazione viene creata dal comando stesso. + # Non passiamo un working_directory specifico a log_and_execute. + result = self.log_and_execute( + command=cmd, + working_directory=".", # Esegui da CWD, Git crea la dir specificata + check=False, # Fondamentale per gestire errori specifici + capture=True, + hide_console=True, + log_output_level=logging.INFO # Logga output (progresso, errori) a INFO + # Timeout aumentato viene gestito internamente da log_and_execute se lo modifichiamo lì, + # altrimenti possiamo passarlo come argomento extra se log_and_execute lo accetta. + # Per ora, assumiamo che il timeout di log_and_execute sia sufficiente o lo aumentiamo lì. + ) + return result # --- END OF FILE git_commands.py --- diff --git a/gui.py b/gui.py index 49ad986..a198212 100644 --- a/gui.py +++ b/gui.py @@ -449,6 +449,7 @@ class MainFrame(ttk.Frame): push_remote_cb, push_tags_remote_cb, refresh_remote_status_cb, + clone_remote_repo_cb, ): """Initializes the MainFrame.""" super().__init__(master) @@ -485,6 +486,7 @@ class MainFrame(ttk.Frame): self.push_remote_callback = push_remote_cb self.push_tags_remote_callback = push_tags_remote_cb self.refresh_remote_status_callback = refresh_remote_status_cb + self.clone_remote_repo_callback = clone_remote_repo_cb # Configure style (invariato) self.style = ttk.Style() @@ -611,6 +613,16 @@ class MainFrame(ttk.Frame): ) self.add_profile_button.pack(side=tk.LEFT, padx=(2, 2), pady=5) self.create_tooltip(self.add_profile_button, "Add new profile.") + + self.clone_profile_button = ttk.Button( + button_subframe, + text="Clone from Remote", # Testo aggiornato + width=18, # Leggermente più largo + command=self.clone_remote_repo_callback # Chiama il nuovo callback + ) + self.clone_profile_button.pack(side=tk.LEFT, padx=5, pady=5) + self.create_tooltip(self.clone_profile_button, "Clone a remote repository into a new local directory and create a profile for it.") + self.remove_profile_button = ttk.Button( button_subframe, text="Remove", @@ -1981,4 +1993,116 @@ class MainFrame(ttk.Frame): log_handler.log_error(f"Failed to update sync status variable: {e}", func_name="update_ahead_behind_status") +class CloneFromRemoteDialog(simpledialog.Dialog): + """Dialog to get Remote URL and Local Parent Directory for cloning.""" + + def __init__(self, parent, title="Clone Remote Repository"): + self.remote_url_var = tk.StringVar() + self.local_parent_dir_var = tk.StringVar() + self.profile_name_var = tk.StringVar() # Opzionale + self.result = None # Conterrà la tupla (url, parent_dir, profile_name) + # Imposta directory iniziale suggerita per la cartella locale + self.local_parent_dir_var.set(os.path.expanduser("~")) # Default alla home + super().__init__(parent, title=title) + + def body(self, master): + """Creates the dialog body.""" + main_frame = ttk.Frame(master, padding="10") + main_frame.pack(fill="both", expand=True) + main_frame.columnconfigure(1, weight=1) # Colonna delle entry si espande + + row_idx = 0 + + # Remote URL + ttk.Label(main_frame, text="Remote Repository URL:").grid( + row=row_idx, column=0, padx=5, pady=5, sticky="w") + self.url_entry = ttk.Entry(main_frame, textvariable=self.remote_url_var, width=60) + self.url_entry.grid(row=row_idx, column=1, columnspan=2, padx=5, pady=5, sticky="ew") + Tooltip(self.url_entry, "Enter the full URL (HTTPS or SSH) of the repository to clone.") + row_idx += 1 + + # Local Parent Directory + ttk.Label(main_frame, text="Clone into Directory:").grid( + row=row_idx, column=0, padx=5, pady=5, sticky="w") + self.dir_entry = ttk.Entry(main_frame, textvariable=self.local_parent_dir_var, width=60) + self.dir_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + Tooltip(self.dir_entry, "Select the PARENT directory where the new repository folder will be created.") + self.browse_button = ttk.Button(main_frame, text="Browse...", width=9, command=self._browse_local_dir) + self.browse_button.grid(row=row_idx, column=2, padx=(0, 5), pady=5, sticky="w") + row_idx += 1 + + # Info sulla cartella creata (Label esplicativo) + ttk.Label(main_frame, text="(A new sub-folder named after the repository will be created inside this directory)", + font=("Segoe UI", 8), foreground="grey").grid( + row=row_idx, column=1, columnspan=2, padx=5, pady=(0, 5), sticky="w") + row_idx += 1 + + # New Profile Name (Opzionale) + ttk.Label(main_frame, text="New Profile Name (Optional):").grid( + row=row_idx, column=0, padx=5, pady=5, sticky="w") + self.profile_entry = ttk.Entry(main_frame, textvariable=self.profile_name_var, width=60) + self.profile_entry.grid(row=row_idx, column=1, columnspan=2, padx=5, pady=5, sticky="ew") + Tooltip(self.profile_entry, "Enter a name for the new profile. If left empty, the repository name will be used.") + row_idx += 1 + + + return self.url_entry # initial focus + + def _browse_local_dir(self): + """Callback for the local directory browse button.""" + current_path = self.local_parent_dir_var.get() + initial_dir = current_path if os.path.isdir(current_path) else os.path.expanduser("~") + directory = filedialog.askdirectory( + initialdir=initial_dir, + title="Select Parent Directory for Clone", + parent=self # Rendi modale rispetto a questo dialogo + ) + if directory: + self.local_parent_dir_var.set(directory) + + def validate(self): + """Validates the input fields before closing.""" + url = self.remote_url_var.get().strip() + parent_dir = self.local_parent_dir_var.get().strip() + profile_name = self.profile_name_var.get().strip() # Pulisce anche nome profilo + + if not url: + messagebox.showwarning("Input Error", "Remote Repository URL cannot be empty.", parent=self) + return 0 # Fallisce validazione + + # Verifica base URL (non una validazione completa, ma meglio di niente) + if not (url.startswith("http://") or url.startswith("https://") or url.startswith("ssh://") or "@" in url): + if not messagebox.askokcancel("URL Format Warning", + f"The URL '{url}' does not look like a standard HTTPS, HTTP, or SSH URL.\n\nProceed anyway?", + icon='warning', parent=self): + return 0 + + if not parent_dir: + messagebox.showwarning("Input Error", "Parent Local Directory cannot be empty.", parent=self) + return 0 + + if not os.path.isdir(parent_dir): + messagebox.showwarning("Input Error", f"The selected parent directory does not exist:\n{parent_dir}", parent=self) + return 0 + + # Non validiamo qui se la sotto-cartella esiste, lo farà il controller principale + + # Validazione opzionale nome profilo (se fornito) + if profile_name: + # Applica regole base per nomi di sezione configparser (evita spazi/caratteri speciali?) + # Per semplicità, controlliamo solo che non sia vuoto dopo strip (già fatto) + # Potremmo aggiungere un check regex se necessario. + pass + + return 1 # Validazione OK + + def apply(self): + """Stores the validated result.""" + # Restituisce una tupla con i valori puliti + self.result = ( + self.remote_url_var.get().strip(), + self.local_parent_dir_var.get().strip(), + self.profile_name_var.get().strip() # Restituisce vuoto se non specificato + ) + # --- END OF FILE gui.py ---