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
This commit is contained in:
2026-08-15 17:23:40 +02:00
parent 37d2152fcd
commit 32832f5db7
4 changed files with 87 additions and 29 deletions
+28 -13
View File
@@ -683,9 +683,17 @@ pub async fn rename_remote_branch(
remote: String, remote: String,
old_branch: String, old_branch: String,
new_branch: String, new_branch: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
run_git_task("Could not rename remote branch", move || { 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 .await
} }
@@ -695,6 +703,7 @@ fn rename_remote_branch_core(
remote: String, remote: String,
old_branch: String, old_branch: String,
new_branch: String, new_branch: String,
credentials: Option<(&str, &str)>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?; 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 // 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. // the old one in a single atomic push so a rejected update leaves both untouched.
run_git( let push_args = [
&repo, "push",
[ "--atomic",
"push", source_lease.as_str(),
"--atomic", destination_lease.as_str(),
source_lease.as_str(), remote.as_str(),
destination_lease.as_str(), create_refspec.as_str(),
remote.as_str(), delete_refspec.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 // Git normally updates remote-tracking refs after a successful push. Keep the
// local view consistent as a fallback for unusual remote/refspec setups. // local view consistent as a fallback for unusual remote/refspec setups.
@@ -7662,6 +7676,7 @@ mod tests {
"origin".to_string(), "origin".to_string(),
"feature/old-name".to_string(), "feature/old-name".to_string(),
"feature/new-name".to_string(), "feature/new-name".to_string(),
None,
) )
.unwrap(); .unwrap();
+42 -10
View File
@@ -188,7 +188,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error"; type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository"; type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone"; type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename";
type CredentialMode = "credentials" | "token"; type CredentialMode = "credentials" | "token";
type PendingDiscard = type PendingDiscard =
| { kind: "file"; files: GitFileStatus[]; staged: boolean } | { kind: "file"; files: GitFileStatus[]; staged: boolean }
@@ -410,6 +410,7 @@
let autoRefreshInFlight = false; let autoRefreshInFlight = false;
let credDialogOpen = false; let credDialogOpen = false;
let credDialogAction: CredentialAction | null = null; let credDialogAction: CredentialAction | null = null;
let pendingRemoteRename: { remote: string; oldBranch: string; newBranch: string } | null = null;
let credDialogError = ""; let credDialogError = "";
let credDialogKey: string | null = null; let credDialogKey: string | null = null;
let credDialogUsername = ""; let credDialogUsername = "";
@@ -2622,12 +2623,14 @@
const oldRemoteBranch = branch.name.slice(slash + 1); const oldRemoteBranch = branch.name.slice(slash + 1);
if (name === oldRemoteBranch) return; if (name === oldRemoteBranch) return;
await runOperation(`Renaming ${branch.name} on remote`, async () => { pendingRemoteRename = { remote, oldBranch: oldRemoteBranch, newBranch: name };
applyStatus(await renameRemoteBranch(activeRepoPath, remote, oldRemoteBranch, name)); const key = await currentCredKey("rename");
renameBranchTarget = null; const stored = await loadStoredCredential(key);
await refreshRefsAndCommitGraph(activeRepoPath); if (stored && (!key || !rejectedCredentialKeys.has(key))) {
trackEvent("branch_renamed", { remote: 1 }); await doActualRemoteRename(stored.username, stored.password, key, true, credentialModeFor(stored));
}); } else {
await openCredentialDialog("rename", key, stored);
}
return; return;
} }
@@ -3231,10 +3234,11 @@
// Resolve the keychain key (host/org) from the exact remote URL used by the // Resolve the keychain key (host/org) from the exact remote URL used by the
// operation. Push URLs may intentionally differ from fetch URLs. // operation. Push URLs may intentionally differ from fetch URLs.
async function currentCredKey(action: "push" | "pull" | "fetch" = "fetch"): Promise<string | null> { async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" = "fetch"): Promise<string | null> {
if (!activeRepoPath) return null; if (!activeRepoPath) return null;
try { 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; return url ? orgKeyFromUrl(url) : null;
} catch { } catch {
return null; return null;
@@ -3273,7 +3277,7 @@
// so a temporary 401/403 cannot erase a valid token; the key is only skipped // 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. // for the rest of this session until the user replaces it successfully.
function handleRemoteResult( function handleRemoteResult(
action: "push" | "pull" | "fetch", action: "push" | "pull" | "fetch" | "rename",
key: string | null, key: string | null,
fromStore: boolean, fromStore: boolean,
username: string, username: string,
@@ -3414,6 +3418,33 @@
handleRemoteResult("push", key, fromStore, username, mode); 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( async function handleCredentialSubmit(
username: string, username: string,
password: string, password: string,
@@ -3436,6 +3467,7 @@
if (credDialogAction === "pull") await doActualPull(username, password, key, false, mode); if (credDialogAction === "pull") await doActualPull(username, password, key, false, mode);
else if (credDialogAction === "push") await doActualPush(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 === "fetch") await doActualFetch(username, password, key, false, mode);
else if (credDialogAction === "rename") await doActualRemoteRename(username, password, key, false, mode);
else if (credDialogAction === "clone" && pendingClone) { else if (credDialogAction === "clone" && pendingClone) {
await cloneRepo( await cloneRepo(
pendingClone.remoteUrl, pendingClone.remoteUrl,
+7 -5
View File
@@ -15,7 +15,7 @@
} from "@lucide/svelte"; } from "@lucide/svelte";
interface Props { interface Props {
action: "push" | "pull" | "fetch" | "clone"; action: "push" | "pull" | "fetch" | "clone" | "rename";
error: string; error: string;
isBusy: boolean; isBusy: boolean;
initialUsername?: string; initialUsername?: string;
@@ -47,17 +47,19 @@
password.trim().length > 0 && password.trim().length > 0 &&
username.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( let actionTitle = $derived(
action === "push" action === "push"
? "Authenticate push" ? "Authenticate push"
: action === "fetch" : action === "rename"
? "Authenticate remote rename"
: action === "fetch"
? "Authenticate fetch" ? "Authenticate fetch"
: action === "clone" : action === "clone"
? "Authenticate clone" ? "Authenticate clone"
: "Authenticate pull", : "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." ? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: action === "clone" : action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token." ? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
@@ -78,7 +80,7 @@
<div class="cred-hero"> <div class="cred-hero">
<div class="cred-hero-top"> <div class="cred-hero-top">
<div class="cred-hero-icon"> <div class="cred-hero-icon">
{#if action === "push"} {#if action === "push" || action === "rename"}
<Upload size={27} aria-hidden="true" /> <Upload size={27} aria-hidden="true" />
{:else} {:else}
<Download size={27} aria-hidden="true" /> <Download size={27} aria-hidden="true" />
+10 -1
View File
@@ -138,8 +138,17 @@ export function renameRemoteBranch(
remote: string, remote: string,
oldBranch: string, oldBranch: string,
newBranch: string, newBranch: string,
username?: string,
password?: string,
): Promise<GitStatus> { ): Promise<GitStatus> {
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch }); return invoke<GitStatus>("rename_remote_branch", {
path,
remote,
oldBranch,
newBranch,
username: username ?? null,
password: password ?? null,
});
} }
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> { export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {