diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 945414a..60302cd 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -651,30 +651,136 @@ pub fn set_branch_upstream( status_for_repo(&repo) } -#[tauri::command(async)] -pub fn delete_remote_branch( +#[tauri::command] +pub async fn delete_remote_branch( path: String, remote: String, branch: String, + username: Option, + password: Option, ) -> Result { - log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}"); - let result = (|| { + run_git_task("Could not delete remote branch", move || { let repo = resolve_repo(&path)?; let remote = validate_remote_name(&repo, &remote, true)?; + delete_remote_branches_core( + &repo, + &remote, + vec![branch], + username.as_deref().zip(password.as_deref()), + ) + }) + .await +} + +#[tauri::command] +pub async fn delete_remote_branches( + path: String, + remote: String, + branches: Vec, + username: Option, + password: Option, +) -> Result { + run_git_task("Could not delete remote branch folder", move || { + let repo = resolve_repo(&path)?; + let remote = validate_remote_name(&repo, &remote, true)?; + delete_remote_branches_core( + &repo, + &remote, + branches, + username.as_deref().zip(password.as_deref()), + ) + }) + .await +} + +fn delete_remote_branches_core( + repo: &Path, + remote: &str, + branches: Vec, + credentials: Option<(&str, &str)>, +) -> Result { + let run_remote = |args: Vec| match credentials { + Some((user, pass)) if !user.is_empty() || !pass.is_empty() => { + run_git_authenticated(repo, args, user, pass) + } + _ => run_git(repo, args), + }; + + let mut branch_names = Vec::new(); + for branch in branches { 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])?; + if !branch_names.iter().any(|existing| existing == branch) { + branch_names.push(branch.to_string()); + } } - result + if branch_names.is_empty() { + return Err("No remote branches were selected.".to_string()); + } + + // Local remote-tracking refs can be stale when a branch was deleted by + // another client. Query the server first so deletion remains idempotent. + let mut query_args = vec![ + "ls-remote".to_string(), + "--heads".to_string(), + remote.to_string(), + ]; + query_args.extend( + branch_names + .iter() + .map(|branch| format!("refs/heads/{branch}")), + ); + let remote_refs = String::from_utf8_lossy(&run_remote(query_args)?).to_string(); + let existing_refs: BTreeSet<&str> = remote_refs + .lines() + .filter_map(|line| line.split_once('\t').map(|(_, reference)| reference.trim())) + .collect(); + branch_names.retain(|branch| existing_refs.contains(format!("refs/heads/{branch}").as_str())); + + if !branch_names.is_empty() { + let mut atomic_args = vec![ + "push".to_string(), + "--atomic".to_string(), + remote.to_string(), + "--delete".to_string(), + ]; + atomic_args.extend(branch_names.iter().cloned()); + + if let Err(error) = run_remote(atomic_args) { + let message = error.to_lowercase(); + let atomic_unsupported = message.contains("does not support --atomic") + || message.contains("atomic push is not supported") + || message.contains("does not support atomic push"); + if !atomic_unsupported { + return Err(error); + } + + log::warn!( + target: "gitty::remote", + "remote {remote} does not support atomic pushes; retrying branch-folder deletion as one regular push" + ); + let mut fallback_args = vec![ + "push".to_string(), + remote.to_string(), + "--delete".to_string(), + ]; + fallback_args.extend(branch_names); + run_remote(fallback_args)?; + } + } + + // Also remove stale tracking refs for branches that were already absent. + if let Err(error) = run_remote(vec![ + "fetch".to_string(), + "--prune".to_string(), + remote.to_string(), + ]) { + log::warn!(target: "gitty::remote", "remote branches were deleted, but tracking refs could not be pruned: {error}"); + } + status_for_repo(repo) } #[tauri::command] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a5df731..619935a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -16,8 +16,8 @@ use git::{ commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch, - delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note, - get_file_blame, get_file_patch, get_remote_url, get_status, init_repository, + delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, + get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, init_repository, last_commit_message, list_branches, list_commits, list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch, @@ -141,6 +141,7 @@ async fn main() { remove_remote, set_branch_upstream, delete_remote_branch, + delete_remote_branches, list_stashes, checkout_branch, create_branch, diff --git a/src/App.svelte b/src/App.svelte index 522e9a6..d323ec4 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -60,6 +60,7 @@ deleteCommitNote, deleteTag, deleteRemoteBranch, + deleteRemoteBranches, initRepository, diffFileAgainstWorkingTree, compareFileToParent, @@ -188,7 +189,7 @@ type UpdateToastState = "available" | "downloading" | "installed" | "error"; type AppView = "management" | "repository"; - type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename"; + type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete"; type CredentialMode = "credentials" | "token"; type PendingDiscard = | { kind: "file"; files: GitFileStatus[]; staged: boolean } @@ -411,6 +412,7 @@ let credDialogOpen = false; let credDialogAction: CredentialAction | null = null; let pendingRemoteRename: { remote: string; oldBranch: string; newBranch: string } | null = null; + let pendingRemoteDelete: { remote: string; branches: string[]; label: string; folder: boolean } | null = null; let credDialogError = ""; let credDialogKey: string | null = null; let credDialogUsername = ""; @@ -1974,6 +1976,7 @@ globalSearchResults = []; deleteBranchTarget = null; deleteBranchForce = false; + pendingRemoteDelete = null; worktreeDialogOpen = false; worktreeInitialBranch = ""; worktrees = []; @@ -2675,19 +2678,35 @@ if (!window.confirm(`Delete ${deletableBranches.length} ${scope} branches in “${folderName}”?${currentNote}`)) return; const repoPath = activeRepoPath; + if (remoteFolder) { + const remoteBranches = deletableBranches.map((branch) => { + const slash = branch.name.indexOf("/"); + if (slash < 1) throw new Error(`Could not determine the remote for ${branch.name}.`); + return { remote: branch.name.slice(0, slash), name: branch.name.slice(slash + 1) }; + }); + const remote = remoteBranches[0]?.remote; + if (!remote || remoteBranches.some((branch) => branch.remote !== remote)) { + errorMessage = "A remote branch folder must belong to exactly one remote."; + return; + } + pendingRemoteDelete = { remote, branches: remoteBranches.map((branch) => branch.name), label: folderName, folder: true }; + const key = await currentCredKey("delete"); + const stored = await loadStoredCredential(key); + if (stored && (!key || !rejectedCredentialKeys.has(key))) { + await doActualRemoteDelete(stored.username, stored.password, key, true, credentialModeFor(stored)); + } else { + await openCredentialDialog("delete", key, stored); + } + return; + } + const failures: string[] = []; operation = `Deleting branches in ${folderName}`; errorMessage = ""; try { for (const branch of deletableBranches) { try { - if (branch.remote) { - const slash = branch.name.indexOf("/"); - if (slash < 1) throw new Error("Could not determine remote name."); - applyStatus(await deleteRemoteBranch(repoPath, branch.name.slice(0, slash), branch.name.slice(slash + 1))); - } else { - applyStatus(await deleteBranch(repoPath, branch.name, false)); - } + applyStatus(await deleteBranch(repoPath, branch.name, false)); } catch (error) { failures.push(`${branch.name}: ${errorToMessage(error)}`); } @@ -2696,7 +2715,7 @@ trackEvent("branch_folder_deleted", { attempted: deletableBranches.length, failed: failures.length, - remote: remoteFolder ? 1 : 0, + remote: 0, }); if (failures.length > 0) { errorMessage = `${failures.length} branch${failures.length === 1 ? "" : "es"} could not be deleted:\n${failures.join("\n")}`; @@ -2715,17 +2734,13 @@ 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 = ""; + pendingRemoteDelete = { remote, branches: [remoteBranch], label: branch.name, folder: false }; + const key = await currentCredKey("delete"); + const stored = await loadStoredCredential(key); + if (stored && (!key || !rejectedCredentialKeys.has(key))) { + await doActualRemoteDelete(stored.username, stored.password, key, true, credentialModeFor(stored)); + } else { + await openCredentialDialog("delete", key, stored); } return; } @@ -3284,11 +3299,11 @@ // Resolve the keychain key (host/org) from the exact remote URL used by the // operation. Push URLs may intentionally differ from fetch URLs. - async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" = "fetch"): Promise { + async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" | "delete" = "fetch"): Promise { if (!activeRepoPath) return null; try { - const remote = action === "rename" ? pendingRemoteRename?.remote : selectedRemote; - const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename"); + const remote = action === "rename" ? pendingRemoteRename?.remote : action === "delete" ? pendingRemoteDelete?.remote : selectedRemote; + const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename" || action === "delete"); return url ? orgKeyFromUrl(url) : null; } catch { return null; @@ -3327,7 +3342,7 @@ // so a temporary 401/403 cannot erase a valid token; the key is only skipped // for the rest of this session until the user replaces it successfully. function handleRemoteResult( - action: "push" | "pull" | "fetch" | "rename", + action: "push" | "pull" | "fetch" | "rename" | "delete", key: string | null, fromStore: boolean, username: string, @@ -3495,6 +3510,32 @@ handleRemoteResult("rename", key, fromStore, username, mode); } + async function doActualRemoteDelete( + username: string, + password: string, + key: string | null, + fromStore: boolean, + mode: CredentialMode, + ) { + const deletion = pendingRemoteDelete; + if (!activeRepoPath || !deletion) return; + errorMessage = ""; + await runOperation(`Deleting ${deletion.label} from remote`, async () => { + applyStatus(deletion.folder + ? await deleteRemoteBranches(activeRepoPath, deletion.remote, deletion.branches, username, password) + : await deleteRemoteBranch(activeRepoPath, deletion.remote, deletion.branches[0], username, password)); + deleteBranchTarget = null; + pendingRemoteDelete = null; + await refreshRefsAndCommitGraph(activeRepoPath); + if (deletion.folder) { + trackEvent("branch_folder_deleted", { attempted: deletion.branches.length, failed: 0, remote: 1 }); + } else { + trackEvent("remote_branch_deleted"); + } + }); + handleRemoteResult("delete", key, fromStore, username, mode); + } + async function handleCredentialSubmit( username: string, password: string, @@ -3518,6 +3559,7 @@ else if (credDialogAction === "push") await doActualPush(username, password, key, false, mode); else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false, mode); else if (credDialogAction === "rename") await doActualRemoteRename(username, password, key, false, mode); + else if (credDialogAction === "delete") await doActualRemoteDelete(username, password, key, false, mode); else if (credDialogAction === "clone" && pendingClone) { await cloneRepo( pendingClone.remoteUrl, @@ -5615,7 +5657,7 @@ {/await} {/if} - + {#if credDialogOpen && credDialogAction} { credDialogOpen = false; credDialogAction = null; + pendingRemoteDelete = null; credDialogError = ""; credDialogKey = null; credDialogUsername = ""; diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 57239e9..f07e5b9 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -584,7 +584,7 @@ style={`--branch-indent: ${row.depth * 16}px;`} type="button" onclick={() => toggleBranchFolder(row.id)} - oncontextmenu={row.depth > 0 ? (event) => openFolderContextMenu(event, row) : undefined} + oncontextmenu={(event) => openFolderContextMenu(event, row)} aria-expanded={isBranchFolderOpen(row.id)} title={`${row.name} (${row.branchCount})`} > diff --git a/src/lib/components/CredentialDialog.svelte b/src/lib/components/CredentialDialog.svelte index 58455f5..42a44c7 100644 --- a/src/lib/components/CredentialDialog.svelte +++ b/src/lib/components/CredentialDialog.svelte @@ -15,7 +15,7 @@ } from "@lucide/svelte"; interface Props { - action: "push" | "pull" | "fetch" | "clone" | "rename"; + action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete"; error: string; isBusy: boolean; initialUsername?: string; @@ -47,19 +47,21 @@ password.trim().length > 0 && username.trim().length > 0, ); - let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : "Pull"); + let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull"); let actionTitle = $derived( action === "push" ? "Authenticate push" : action === "rename" ? "Authenticate remote rename" - : action === "fetch" - ? "Authenticate fetch" - : action === "clone" - ? "Authenticate clone" - : "Authenticate pull", + : action === "delete" + ? "Authenticate remote deletion" + : action === "fetch" + ? "Authenticate fetch" + : action === "clone" + ? "Authenticate clone" + : "Authenticate pull", ); - let actionHint = $derived(action === "push" || action === "rename" + let actionHint = $derived(action === "push" || action === "rename" || action === "delete" ? "The remote needs write access. Use a password or a token with the appropriate repository permissions." : action === "clone" ? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token." @@ -80,7 +82,7 @@
- {#if action === "push" || action === "rename"} + {#if action === "push" || action === "rename" || action === "delete"}