feat(branches): add force delete flow for local branches
publish / publish-tauri (, windows-latest) (release) Successful in 22m57s

Branch deletion now supports an optional force mode in the Tauri
command, enabling deletion of branches that are not fully merged.
The UI replaces the simple confirm prompt with a dedicated dialog and
auto-escalates to force delete when Git rejects a normal delete.

- Add force flag to delete_branch command and tests
- Implement BranchDeleteConfirmDialog and wire it into App.svelte
- Update branch context menu actions and styling
This commit is contained in:
Christoph Brandau
2026-07-04 00:52:40 +02:00
parent c2d7fefb47
commit 9a4c6e5b9b
6 changed files with 239 additions and 46 deletions
+52 -5
View File
@@ -6,6 +6,7 @@
import TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte";
@@ -172,6 +173,8 @@
let comparison: GitCommitComparison | null = null;
let newBranchCommit: GitCommit | null = null;
let renameBranchTarget: GitBranchInfo | null = null;
let deleteBranchTarget: GitBranchInfo | null = null;
let deleteBranchForce = false;
let compareSelectOpen = false;
let compareDialogOpen = false;
let selectedDiffPath = "";
@@ -777,6 +780,8 @@
pendingRestoreFile = null;
newBranchCommit = null;
globalSearchResults = [];
deleteBranchTarget = null;
deleteBranchForce = false;
globalSearchOpen = false;
globalSearchError = "";
resolveDialogOpen = false;
@@ -812,6 +817,11 @@
return (value?.files ?? []).some((file) => file.staged === "conflicted" || file.unstaged === "conflicted");
}
function isBranchNotFullyMergedError(message: string): boolean {
const value = message.toLowerCase();
return value.includes("not fully merged") || value.includes("run 'git branch -d'");
}
async function runOperation(label: string, task: () => Promise<void>) {
if (isBusy) return;
operation = label;
@@ -1084,16 +1094,41 @@
return;
}
const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`);
if (!confirmed) return;
deleteBranchTarget = branch;
deleteBranchForce = false;
}
await runOperation(`Deleting ${branch.name}`, async () => {
applyStatus(await deleteBranch(activeRepoPath, branch.name));
async function confirmDeleteBranch() {
const branch = deleteBranchTarget;
if (!activeRepoPath || !branch || branch.remote || branch.current || isBusy) return;
operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`;
errorMessage = "";
try {
applyStatus(await deleteBranch(activeRepoPath, branch.name, deleteBranchForce));
deleteBranchTarget = null;
deleteBranchForce = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
} catch (error) {
const message = errorToMessage(error);
if (deleteBranchForce || !isBranchNotFullyMergedError(message)) {
errorMessage = message;
return;
}
deleteBranchForce = true;
} finally {
operation = "";
}
}
function closeDeleteBranchDialog() {
if (isBusy) return;
deleteBranchTarget = null;
deleteBranchForce = false;
}
function openNewBranchDialog(commit: GitCommit) {
@@ -1881,6 +1916,7 @@
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
}
@@ -2374,6 +2410,17 @@
/>
{/if}
<!-- Delete a local branch from the branch context menu -->
{#if deleteBranchTarget}
<BranchDeleteConfirmDialog
branch={deleteBranchTarget}
force={deleteBranchForce}
{isBusy}
onConfirm={confirmDeleteBranch}
onClose={closeDeleteBranchDialog}
/>
{/if}
<!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen}
<AiSettingsDialog
+17
View File
@@ -1679,6 +1679,12 @@
color: var(--color-ink);
}
.branch-context-menu .menu-separator {
height: 1px;
margin: 4px 3px;
background: var(--color-border-subtle);
}
.branch-context-menu button.danger {
color: #ff9aa8;
}
@@ -2645,6 +2651,12 @@
gap: 14px;
padding: 18px 16px 16px;
}
.branch-delete-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
padding: 18px 16px 16px;
}
.discard-warning-icon {
display: grid;
place-items: center;
@@ -2679,6 +2691,11 @@
white-space: pre-wrap;
word-break: break-word;
}
.branch-delete-body .discard-target {
display: flex;
align-items: center;
gap: 6px;
}
.discard-warning-text {
color: #ffb8bf;
font-weight: 650;
@@ -0,0 +1,80 @@
<script lang="ts">
import { AlertTriangle, GitBranch, LoaderCircle, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
interface Props {
branch: GitBranchInfo;
force: boolean;
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
branch,
force = false,
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
function closeFromBackdrop(event: MouseEvent) {
if (isBusy || event.target !== event.currentTarget) return;
onClose();
}
</script>
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
<p class="dialog-title">{title}</p>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-delete-body">
<div class="discard-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p>
{#if force}
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
{:else}
Delete this local branch from the repository?
{/if}
</p>
<code class="discard-target" title={branch.name}>
<GitBranch size={13} aria-hidden="true" />
{branch.name}
</code>
<p class="discard-warning-text">
{#if force}
Make sure you no longer need the unique commits on this branch.
{:else}
Git will refuse if the branch is not fully merged.
{/if}
</p>
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<Trash2 size={15} aria-hidden="true" />
{/if}
{force ? "Force delete" : "Delete"}
</button>
</footer>
</div>
</div>
+41 -34
View File
@@ -218,13 +218,13 @@
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault();
event.stopPropagation();
if (isBusy || branch.remote) return;
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 92);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
@@ -244,11 +244,32 @@
async function deleteContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
if (!branch || branch.current || branch.remote || isBusy) return;
closeBranchContextMenu();
await onDeleteBranch(branch);
}
async function checkoutContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onCheckout(branch);
}
async function mergeContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onMerge(branch);
}
async function rebaseContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onRebase(branch);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu();
}
@@ -363,20 +384,6 @@
</div>
{#if row.branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
<button class="btn-sm" type="button" onclick={() => onRebase(row.branch)} disabled={isBusy} title="Rebase current branch onto this branch">
<GitBranch size={15} aria-hidden="true" />
Rebase
</button>
</div>
{/if}
</article>
{/if}
@@ -432,6 +439,7 @@
class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name}
>
<div class="branch-info">
@@ -443,20 +451,6 @@
</div>
{#if row.branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
<button class="btn-sm" type="button" onclick={() => onRebase(row.branch)} disabled={isBusy} title="Rebase current branch onto this branch">
<GitBranch size={15} aria-hidden="true" />
Rebase
</button>
</div>
{/if}
</article>
{/if}
@@ -475,7 +469,20 @@
tabindex="-1"
aria-label={`Actions for ${contextBranch.name}`}
>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
<button type="button" role="menuitem" onclick={checkoutContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Checkout
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" />
Merge into current
</button>
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Rebase current onto this
</button>
<div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
<Pencil size={14} aria-hidden="true" />
Rename
</button>
@@ -484,8 +491,8 @@
type="button"
role="menuitem"
onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current}
title={contextBranch.current ? "Current branch cannot be deleted" : "Delete local branch"}
disabled={isBusy || contextBranch.current || contextBranch.remote}
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
>
<Trash2 size={14} aria-hidden="true" />
Delete
+2 -2
View File
@@ -72,8 +72,8 @@ export function renameBranch(
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
}
export function deleteBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch });
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch, force });
}
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {