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:
+68
-25
@@ -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 = "";
|
||||
|
||||
@@ -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})`}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user