From 9a4c6e5b9ba897d1ea513b72592d94d68593e29d Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sat, 4 Jul 2026 00:52:40 +0200 Subject: [PATCH] feat(branches): add force delete flow for local branches 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 --- src-tauri/src/git.rs | 52 ++++++++++-- src/App.svelte | 57 +++++++++++-- src/app.css | 17 ++++ .../BranchDeleteConfirmDialog.svelte | 80 +++++++++++++++++++ src/lib/components/BranchPanel.svelte | 75 +++++++++-------- src/lib/git.ts | 4 +- 6 files changed, 239 insertions(+), 46 deletions(-) create mode 100644 src/lib/components/BranchDeleteConfirmDialog.svelte diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index ff26b0b..7b3278a 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -530,7 +530,11 @@ pub fn rename_branch( } #[tauri::command] -pub fn delete_branch(path: String, branch: String) -> Result { +pub fn delete_branch( + path: String, + branch: String, + force: Option, +) -> Result { let repo = resolve_repo(&path)?; let branch = validate_existing_local_branch_name(&repo, &branch)?; let status = status_for_repo(&repo)?; @@ -538,7 +542,8 @@ pub fn delete_branch(path: String, branch: String) -> Result return Err("The current branch cannot be deleted.".to_string()); } - run_git(&repo, ["branch", "-d", "--", branch.as_str()])?; + let delete_flag = if force.unwrap_or(false) { "-D" } else { "-d" }; + run_git(&repo, ["branch", delete_flag, "--", branch.as_str()])?; status_for_repo(&repo) } @@ -4522,8 +4527,12 @@ mod tests { let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["branch", "stale"]); - let status = - delete_branch(repo.path.to_string_lossy().to_string(), "stale".to_string()).unwrap(); + let status = delete_branch( + repo.path.to_string_lossy().to_string(), + "stale".to_string(), + None, + ) + .unwrap(); assert_eq!(status.current_branch.as_deref(), Some(current.as_str())); assert!( @@ -4531,10 +4540,43 @@ mod tests { "deleted branch should be gone" ); - let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err(); + let err = + delete_branch(repo.path.to_string_lossy().to_string(), current, None).unwrap_err(); assert!(err.contains("current branch")); } + #[test] + fn delete_branch_can_force_delete_unmerged_branch() { + let repo = init_temp_repo("delete_branch_force"); + commit_initial_file(&repo.path); + let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); + run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/unmerged"]); + fs::write(repo.path.join("feature.txt"), "feature\n") + .expect("feature file should be written"); + run_git_test(&repo.path, ["add", "feature.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "feature"]); + run_git_test(&repo.path, ["checkout", "-q", current.as_str()]); + + let err = delete_branch( + repo.path.to_string_lossy().to_string(), + "feature/unmerged".to_string(), + Some(false), + ) + .unwrap_err(); + assert!(err.contains("not fully merged")); + + delete_branch( + repo.path.to_string_lossy().to_string(), + "feature/unmerged".to_string(), + Some(true), + ) + .unwrap(); + assert!( + !ref_exists(&repo.path, "refs/heads/feature/unmerged").unwrap(), + "force-deleted branch should be gone" + ); + } + #[test] fn apply_file_patch_stages_and_discards_selected_changes() { let repo = init_temp_repo("apply_file_patch"); diff --git a/src/App.svelte b/src/App.svelte index 6efd5f8..cb6fa6a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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) { 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} + +{#if deleteBranchTarget} + +{/if} + {#if aiSettingsOpen} + 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; + 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(); + } + + + diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 6f841ea..746f96a 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -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 @@ {#if row.branch.current} Current - {:else} -
- - - -
{/if} {/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} >
@@ -443,20 +451,6 @@
{#if row.branch.current} Current - {:else} -
- - - -
{/if} {/if} @@ -475,7 +469,20 @@ tabindex="-1" aria-label={`Actions for ${contextBranch.name}`} > - + + + + @@ -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"} >