From 32832f5db762434758823f28603b5e325cdafe68 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sat, 15 Aug 2026 17:23:40 +0200 Subject: [PATCH] feat(git): support credentials for remote branch rename Allow remote branch renames to be performed with optional credentials so operations against protected remotes succeed when authentication is needed. The backend accepts username/password and uses an authenticated push path when provided, while the frontend prompts for and reuses stored credentials. - Add optional username/password to rename RPC and use authenticated push - Wire UI to queue rename, open credential dialog, and execute rename - Extend credential dialog and handling to include the rename action --- src-tauri/src/git.rs | 41 +++++++++++------ src/App.svelte | 52 +++++++++++++++++----- src/lib/components/CredentialDialog.svelte | 12 ++--- src/lib/git.ts | 11 ++++- 4 files changed, 87 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index dd73120..945414a 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -683,9 +683,17 @@ pub async fn rename_remote_branch( remote: String, old_branch: String, new_branch: String, + username: Option, + password: Option, ) -> Result { run_git_task("Could not rename remote branch", move || { - rename_remote_branch_core(path, remote, old_branch, new_branch) + rename_remote_branch_core( + path, + remote, + old_branch, + new_branch, + username.as_deref().zip(password.as_deref()), + ) }) .await } @@ -695,6 +703,7 @@ fn rename_remote_branch_core( remote: String, old_branch: String, new_branch: String, + credentials: Option<(&str, &str)>, ) -> Result { let repo = resolve_repo(&path)?; let remote = validate_remote_name(&repo, &remote, true)?; @@ -730,18 +739,23 @@ fn rename_remote_branch_core( // Git has no standalone remote-rename command. Create the new ref and delete // the old one in a single atomic push so a rejected update leaves both untouched. - run_git( - &repo, - [ - "push", - "--atomic", - source_lease.as_str(), - destination_lease.as_str(), - remote.as_str(), - create_refspec.as_str(), - delete_refspec.as_str(), - ], - )?; + let push_args = [ + "push", + "--atomic", + source_lease.as_str(), + destination_lease.as_str(), + remote.as_str(), + create_refspec.as_str(), + delete_refspec.as_str(), + ]; + match credentials { + Some((username, password)) if !username.is_empty() || !password.is_empty() => { + run_git_authenticated(&repo, push_args, username, password)?; + } + _ => { + run_git(&repo, push_args)?; + } + } // Git normally updates remote-tracking refs after a successful push. Keep the // local view consistent as a fallback for unusual remote/refspec setups. @@ -7662,6 +7676,7 @@ mod tests { "origin".to_string(), "feature/old-name".to_string(), "feature/new-name".to_string(), + None, ) .unwrap(); diff --git a/src/App.svelte b/src/App.svelte index 4ac0e7f..c643360 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -188,7 +188,7 @@ type UpdateToastState = "available" | "downloading" | "installed" | "error"; type AppView = "management" | "repository"; - type CredentialAction = "push" | "pull" | "fetch" | "clone"; + type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename"; type CredentialMode = "credentials" | "token"; type PendingDiscard = | { kind: "file"; files: GitFileStatus[]; staged: boolean } @@ -410,6 +410,7 @@ let autoRefreshInFlight = false; let credDialogOpen = false; let credDialogAction: CredentialAction | null = null; + let pendingRemoteRename: { remote: string; oldBranch: string; newBranch: string } | null = null; let credDialogError = ""; let credDialogKey: string | null = null; let credDialogUsername = ""; @@ -2622,12 +2623,14 @@ const oldRemoteBranch = branch.name.slice(slash + 1); if (name === oldRemoteBranch) return; - await runOperation(`Renaming ${branch.name} on remote`, async () => { - applyStatus(await renameRemoteBranch(activeRepoPath, remote, oldRemoteBranch, name)); - renameBranchTarget = null; - await refreshRefsAndCommitGraph(activeRepoPath); - trackEvent("branch_renamed", { remote: 1 }); - }); + pendingRemoteRename = { remote, oldBranch: oldRemoteBranch, newBranch: name }; + const key = await currentCredKey("rename"); + const stored = await loadStoredCredential(key); + if (stored && (!key || !rejectedCredentialKeys.has(key))) { + await doActualRemoteRename(stored.username, stored.password, key, true, credentialModeFor(stored)); + } else { + await openCredentialDialog("rename", key, stored); + } return; } @@ -3231,10 +3234,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" = "fetch"): Promise { + async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" = "fetch"): Promise { if (!activeRepoPath) return null; try { - const url = await getRemoteUrl(activeRepoPath, selectedRemote || undefined, action === "push"); + const remote = action === "rename" ? pendingRemoteRename?.remote : selectedRemote; + const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename"); return url ? orgKeyFromUrl(url) : null; } catch { return null; @@ -3273,7 +3277,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", + action: "push" | "pull" | "fetch" | "rename", key: string | null, fromStore: boolean, username: string, @@ -3414,6 +3418,33 @@ handleRemoteResult("push", key, fromStore, username, mode); } + async function doActualRemoteRename( + username: string, + password: string, + key: string | null, + fromStore: boolean, + mode: CredentialMode, + ) { + const rename = pendingRemoteRename; + if (!activeRepoPath || !rename) return; + errorMessage = ""; + await runOperation(`Renaming ${rename.remote}/${rename.oldBranch} on remote`, async () => { + applyStatus(await renameRemoteBranch( + activeRepoPath, + rename.remote, + rename.oldBranch, + rename.newBranch, + username, + password, + )); + renameBranchTarget = null; + pendingRemoteRename = null; + await refreshRefsAndCommitGraph(activeRepoPath); + trackEvent("branch_renamed", { remote: 1 }); + }); + handleRemoteResult("rename", key, fromStore, username, mode); + } + async function handleCredentialSubmit( username: string, password: string, @@ -3436,6 +3467,7 @@ if (credDialogAction === "pull") await doActualPull(username, password, key, false, mode); 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 === "clone" && pendingClone) { await cloneRepo( pendingClone.remoteUrl, diff --git a/src/lib/components/CredentialDialog.svelte b/src/lib/components/CredentialDialog.svelte index c881b03..58455f5 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"; + action: "push" | "pull" | "fetch" | "clone" | "rename"; error: string; isBusy: boolean; initialUsername?: string; @@ -47,17 +47,19 @@ password.trim().length > 0 && username.trim().length > 0, ); - let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull"); + let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : "Pull"); let actionTitle = $derived( action === "push" ? "Authenticate push" - : action === "fetch" + : action === "rename" + ? "Authenticate remote rename" + : action === "fetch" ? "Authenticate fetch" : action === "clone" ? "Authenticate clone" : "Authenticate pull", ); - let actionHint = $derived(action === "push" + let actionHint = $derived(action === "push" || action === "rename" ? "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." @@ -78,7 +80,7 @@
- {#if action === "push"} + {#if action === "push" || action === "rename"}