feat(remote): support authenticated deletion of remote branches

Add support for authenticated deletion of remote branches and folders.
The backend adds a batch delete command that queries the remote,
performs an atomic push --delete when possible with a fallback, and
prunes stale tracking refs; it accepts optional credentials. The
frontend and JS API integrate credential handling and prompt users when
authentication is required.

- New batch deletion command with optional username/password for auth.
- Uses ls-remote to scope deletions, tries --atomic then falls back.
- Frontend updates: credential dialog/action, pendingRemoteDelete state.
This commit is contained in:
2026-08-15 19:38:39 +02:00
parent 4f1131e855
commit 0dac3e9f09
6 changed files with 208 additions and 51 deletions
+119 -13
View File
@@ -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<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
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<String>,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
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<String>,
credentials: Option<(&str, &str)>,
) -> Result<GitStatus, String> {
let run_remote = |args: Vec<String>| 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]
+3 -2
View File
@@ -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,
+68 -25
View File
@@ -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<string | null> {
async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" | "delete" = "fetch"): Promise<string | null> {
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}
<!-- Credential dialog for push/pull -->
<!-- Credential dialog for authenticated remote actions -->
{#if credDialogOpen && credDialogAction}
<CredentialDialog
action={credDialogAction}
@@ -5627,6 +5669,7 @@
onCancel={() => {
credDialogOpen = false;
credDialogAction = null;
pendingRemoteDelete = null;
credDialogError = "";
credDialogKey = null;
credDialogUsername = "";
+1 -1
View File
@@ -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})`}
>
+11 -9
View File
@@ -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 @@
<div class="cred-hero">
<div class="cred-hero-top">
<div class="cred-hero-icon">
{#if action === "push" || action === "rename"}
{#if action === "push" || action === "rename" || action === "delete"}
<Upload size={27} aria-hidden="true" />
{:else}
<Download size={27} aria-hidden="true" />
+6 -1
View File
@@ -107,7 +107,12 @@ export function removeRemote(path: string, name: string): Promise<GitRemote[]> {
console.log("remove_remote")
return invoke("remove_remote", { path, name }); }
export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise<GitStatus> { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); }
export function deleteRemoteBranch(path: string, remote: string, branch: string): Promise<GitStatus> { return invoke("delete_remote_branch", { path, remote, branch }); }
export function deleteRemoteBranch(path: string, remote: string, branch: string, username?: string, password?: string): Promise<GitStatus> {
return invoke("delete_remote_branch", { path, remote, branch, username: username ?? null, password: password ?? null });
}
export function deleteRemoteBranches(path: string, remote: string, branches: string[], username?: string, password?: string): Promise<GitStatus> {
return invoke("delete_remote_branches", { path, remote, branches, username: username ?? null, password: password ?? null });
}
export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path });