From 95c8b01ed1331dd0b58c7943a34c91f3a313a47a Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 14 Jul 2026 00:29:37 +0200 Subject: [PATCH] feat(remote): Enhance remote branch management and stability Improved handling for deleting remote branches across the application, enhancing both user experience and backend reliability. This includes adding structured logging to all Git remote operations in Rust, refining UI components to handle remote-specific deletion flows, and providing clear status/error feedback in sync settings. - Standardized styling for action toggles (Stash, Branch, Explorer) using consistent dimensions. - Implemented detailed console logging for all Git remote operations on the backend. - Refined dialogs and sync settings to provide explicit status and error messages during remote management. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/git.rs | 47 +++++++--- src-tauri/src/main.rs | 31 +++++++ src/App.svelte | 53 ++++++++--- src/app.css | 91 ++++++++++++++----- .../BranchDeleteConfirmDialog.svelte | 15 ++- src/lib/components/BranchPanel.svelte | 2 +- src/lib/components/SyncSettingsDialog.svelte | 31 ++++++- src/lib/git.ts | 4 +- 10 files changed, 219 insertions(+), 57 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4284170..9e300b4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2537,6 +2537,7 @@ version = "0.1.0" dependencies = [ "commit_ai", "keyring", + "log", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 51920cc..f81922f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri-plugin-aptabase = "1.0" keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] } commit_ai = { path = "crates/commit_ai" } tokio = "1.52.3" +log = "0.4" [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index c182510..90d9b8f 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -493,6 +493,7 @@ pub fn list_branches(path: String) -> Result, String> { #[tauri::command] pub fn list_remotes(path: String) -> Result, String> { + log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}"); let repo = resolve_repo(&path)?; let names = run_git(&repo, ["remote"])?; Ok(String::from_utf8_lossy(&names) @@ -531,10 +532,23 @@ pub fn update_remote(path: String, name: String, url: String) -> Result Result, String> { - let repo = resolve_repo(&path)?; - let name = validate_remote_name(&repo, &name, true)?; - run_git(&repo, ["remote", "remove", name.as_str()])?; - list_remotes(path) + log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}"); + let result = (|| { + let repo = resolve_repo(&path)?; + log::info!(target: "gitty::remote", "remove_remote resolved repository: {}", repo.display()); + let name = validate_remote_name(&repo, &name, true)?; + log::info!(target: "gitty::remote", "remove_remote validated remote: {name}"); + run_git(&repo, ["remote", "remove", name.as_str()])?; + log::info!(target: "gitty::remote", "remove_remote git command succeeded: {name}"); + list_remotes(path) + })(); + match &result { + Ok(remotes) => { + log::info!(target: "gitty::remote", "remove_remote completed; remaining={:?}", remotes.iter().map(|remote| remote.name.as_str()).collect::>()) + } + Err(error) => log::error!(target: "gitty::remote", "remove_remote failed: {error}"), + } + result } #[tauri::command] @@ -576,15 +590,24 @@ pub fn delete_remote_branch( remote: String, branch: String, ) -> Result { - let repo = resolve_repo(&path)?; - let remote = validate_remote_name(&repo, &remote, true)?; - let branch = branch.trim(); - if branch.is_empty() || branch.starts_with('-') { - return Err("Invalid remote branch name.".to_string()); + log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}"); + let result = (|| { + let repo = resolve_repo(&path)?; + let remote = validate_remote_name(&repo, &remote, true)?; + let branch = branch.trim(); + if branch.is_empty() || branch.starts_with('-') { + return Err("Invalid remote branch name.".to_string()); + } + run_git(&repo, ["check-ref-format", "--branch", branch])?; + log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}"); + run_git(&repo, ["push", remote.as_str(), "--delete", branch])?; + log::info!(target: "gitty::remote", "remote branch deleted successfully: {remote}/{branch}"); + status_for_repo(&repo) + })(); + if let Err(error) = &result { + log::error!(target: "gitty::remote", "delete_remote_branch failed: {error}"); } - run_git(&repo, ["check-ref-format", "--branch", branch])?; - run_git(&repo, ["push", remote.as_str(), "--delete", branch])?; - status_for_repo(&repo) + result } #[tauri::command] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index aff4d15..8e0d866 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -25,6 +25,36 @@ use git::{ }; use tauri::Manager; +struct ConsoleLogger; + +impl log::Log for ConsoleLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= log::Level::Info + } + + fn log(&self, record: &log::Record<'_>) { + if self.enabled(record.metadata()) { + eprintln!( + "[{}] [{}] {}", + record.level(), + record.target(), + record.args() + ); + } + } + + fn flush(&self) {} +} + +static CONSOLE_LOGGER: ConsoleLogger = ConsoleLogger; + +fn init_console_logging() { + if log::set_logger(&CONSOLE_LOGGER).is_ok() { + log::set_max_level(log::LevelFilter::Info); + log::info!(target: "gitty", "Rust console logging initialized"); + } +} + #[tauri::command] fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("splashscreen") { @@ -47,6 +77,7 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { #[tokio::main] async fn main() { + init_console_logging(); if let Some(result) = run_sequence_editor_if_requested() { if let Err(error) = result { eprintln!("{error}"); diff --git a/src/App.svelte b/src/App.svelte index a366617..29fe331 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2276,7 +2276,27 @@ async function confirmDeleteBranch() { const branch = deleteBranchTarget; - if (!activeRepoPath || !branch || branch.remote || branch.current || isBusy) return; + if (!activeRepoPath || !branch || branch.current || isBusy) return; + + if (branch.remote) { + const slash = branch.name.indexOf("/"); + if (slash < 1) { errorMessage = "Could not determine remote name."; return; } + const remote = branch.name.slice(0, slash); + const remoteBranch = branch.name.slice(slash + 1); + operation = `Deleting ${branch.name} from remote`; + errorMessage = ""; + try { + applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch)); + deleteBranchTarget = null; + await refreshRefsAndCommitGraph(activeRepoPath); + trackEvent("remote_branch_deleted"); + } catch (error) { + errorMessage = errorToMessage(error); + } finally { + operation = ""; + } + return; + } operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`; errorMessage = ""; @@ -2809,11 +2829,10 @@ async function deleteTrackedRemoteBranch(branch: GitBranchInfo) { if (!activeRepoPath || !branch.remote) return; - const slash = branch.name.indexOf("/"); - if (slash < 1) { errorMessage = "Could not determine remote name."; return; } - const remote = branch.name.slice(0, slash); const remoteBranch = branch.name.slice(slash + 1); - if (!window.confirm(`Delete '${remoteBranch}' from remote '${remote}'?`)) return; - await runOperation("Deleting remote branch", async () => { applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch)); await refreshRefsAndCommitGraph(activeRepoPath); }); + if (import.meta.env.DEV) console.info("[Gitty remote] remote branch delete requested", branch); + deleteBranchTarget = branch; + deleteBranchForce = false; + trackEvent("remote_branch_delete_dialog_opened"); } async function initializeRepository() { @@ -2873,10 +2892,21 @@ async function addSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await addRemote(activeRepoPath, name, url); await refreshBranchList(activeRepoPath); } async function updateSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await updateRemote(activeRepoPath, name, url); } async function removeSyncRemote(name: string) { - if (!activeRepoPath || !window.confirm(`Remove remote '${name}'? Local commits and branches are kept.`)) return; - syncSettingsRemotes = await removeRemote(activeRepoPath, name); - if (selectedRemote === name) selectedRemote = ""; - await refreshBranchList(activeRepoPath); + if (!activeRepoPath) return; + operation = `Removing remote ${name}`; + try { + syncSettingsRemotes = await removeRemote(activeRepoPath, name); + if (syncSettingsRemotes.some((remote) => remote.name === name)) throw new Error(`Remote '${name}' still exists after removal.`); + if (selectedRemote === name) { selectedRemote = ""; localStorage.setItem("gitlite.selectedRemote", ""); } + applyStatus(await getStatus(activeRepoPath)); + await refreshBranchList(activeRepoPath); + } catch (error) { + const message = errorToMessage(error); + if (import.meta.env.DEV) console.error("[Gitty remote] remove_remote failed", { name, path: activeRepoPath, error }); + throw new Error(message); + } finally { + operation = ""; + } } async function saveStash(message: string, includeUntracked: boolean) { @@ -3667,6 +3697,7 @@ } function handleWindowContextMenu(event: MouseEvent) { + if (import.meta.env.DEV) return; event.preventDefault(); if (repoTabContextMenu) closeRepoTabContextMenu(); } @@ -4404,7 +4435,7 @@ /> {/if} - + {#if deleteBranchTarget} div { display: flex; gap: 8px; } @media (max-width: 720px) { .strategy-options, .sync-fields { grid-template-columns: 1fr; } .remote-add { grid-template-columns: 1fr; } .sync-settings-footer { align-items: stretch; flex-direction: column; } .sync-settings-footer > div { justify-content: flex-end; } } + +/* Compact square controls shared by the Branches, Stash and Explorer headers. */ +.branch-head-actions .branch-create-toggle, +.stash-head-actions .stash-toggle, +.explorer-head-actions .explorer-bulk-button { + box-sizing: border-box; + display: inline-grid; + place-items: center; + inline-size: 24px; + min-inline-size: 24px; + max-inline-size: 24px; + block-size: 24px; + min-block-size: 24px; + max-block-size: 24px; + flex: 0 0 24px; + aspect-ratio: 1 / 1; + padding: 0; + border-radius: 5px; +} .ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); } .ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; } .ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; } diff --git a/src/lib/components/BranchDeleteConfirmDialog.svelte b/src/lib/components/BranchDeleteConfirmDialog.svelte index 0d6b563..3aea620 100644 --- a/src/lib/components/BranchDeleteConfirmDialog.svelte +++ b/src/lib/components/BranchDeleteConfirmDialog.svelte @@ -18,7 +18,8 @@ onClose = () => {}, }: Props = $props(); - let title = $derived(force ? "Force delete branch?" : "Delete branch?"); + let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?"); + let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []); @@ -26,7 +27,7 @@ diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 5a6697b..5731ab4 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -266,7 +266,7 @@ async function deleteContextBranch() { const branch = contextBranch; - if (!branch || branch.current || branch.remote || isBusy) return; + if (!branch || branch.current || isBusy) return; closeBranchContextMenu(); if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch); } diff --git a/src/lib/components/SyncSettingsDialog.svelte b/src/lib/components/SyncSettingsDialog.svelte index af006f8..34823cb 100644 --- a/src/lib/components/SyncSettingsDialog.svelte +++ b/src/lib/components/SyncSettingsDialog.svelte @@ -23,12 +23,37 @@ let newUrl = ""; let editingName = ""; let editingUrl = ""; + let actionError = ""; + let actionStatus = ""; $: de = language === "de"; - function beginEdit(remote: GitRemote) { editingName = remote.name; editingUrl = remote.fetch_url; } + function beginEdit(remote: GitRemote) { actionError = ""; editingName = remote.name; editingUrl = remote.fetch_url; } + async function requestDelete(event: MouseEvent, name: string) { + console.log(name) + event.preventDefault(); + event.stopPropagation(); + editingName = ""; + actionError = ""; + actionStatus = de ? `Remote „${name}“ wird entfernt …` : `Removing remote “${name}” …`; + console.info("[Gitty remote] remove button activated", { name }); + await remove(name); + } function cancelEdit() { editingName = ""; editingUrl = ""; } async function add() { if (!newName.trim() || !newUrl.trim()) return; await onAddRemote(newName.trim(), newUrl.trim()); newName = "origin"; newUrl = ""; } async function update() { if (!editingName || !editingUrl.trim()) return; await onUpdateRemote(editingName, editingUrl.trim()); cancelEdit(); } + async function remove(name: string) { + actionError = ""; + console.log("remove") + try { + await onRemoveRemote(name); + if (draftRemote === name) draftRemote = ""; + if (draftUpstream.startsWith(`${name}/`)) draftUpstream = ""; + } catch (error) { + actionError = error instanceof Error ? error.message : String(error); + } finally { + actionStatus = ""; + } + }