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:
+28
-13
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user