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
+47 -5
View File
@@ -530,7 +530,11 @@ pub fn rename_branch(
} }
#[tauri::command] #[tauri::command]
pub fn delete_branch(path: String, branch: String) -> Result<GitStatus, String> { pub fn delete_branch(
path: String,
branch: String,
force: Option<bool>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let branch = validate_existing_local_branch_name(&repo, &branch)?; let branch = validate_existing_local_branch_name(&repo, &branch)?;
let status = status_for_repo(&repo)?; let status = status_for_repo(&repo)?;
@@ -538,7 +542,8 @@ pub fn delete_branch(path: String, branch: String) -> Result<GitStatus, String>
return Err("The current branch cannot be deleted.".to_string()); 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) status_for_repo(&repo)
} }
@@ -4522,8 +4527,12 @@ mod tests {
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["branch", "stale"]); run_git_test(&repo.path, ["branch", "stale"]);
let status = let status = delete_branch(
delete_branch(repo.path.to_string_lossy().to_string(), "stale".to_string()).unwrap(); repo.path.to_string_lossy().to_string(),
"stale".to_string(),
None,
)
.unwrap();
assert_eq!(status.current_branch.as_deref(), Some(current.as_str())); assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
assert!( assert!(
@@ -4531,10 +4540,43 @@ mod tests {
"deleted branch should be gone" "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")); 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] #[test]
fn apply_file_patch_stages_and_discards_selected_changes() { fn apply_file_patch_stages_and_discards_selected_changes() {
let repo = init_temp_repo("apply_file_patch"); let repo = init_temp_repo("apply_file_patch");
+52 -5
View File
@@ -6,6 +6,7 @@
import TitleBar from "./lib/TitleBar.svelte"; import TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte"; import CompareDialog from "./lib/components/CompareDialog.svelte";
@@ -172,6 +173,8 @@
let comparison: GitCommitComparison | null = null; let comparison: GitCommitComparison | null = null;
let newBranchCommit: GitCommit | null = null; let newBranchCommit: GitCommit | null = null;
let renameBranchTarget: GitBranchInfo | null = null; let renameBranchTarget: GitBranchInfo | null = null;
let deleteBranchTarget: GitBranchInfo | null = null;
let deleteBranchForce = false;
let compareSelectOpen = false; let compareSelectOpen = false;
let compareDialogOpen = false; let compareDialogOpen = false;
let selectedDiffPath = ""; let selectedDiffPath = "";
@@ -777,6 +780,8 @@
pendingRestoreFile = null; pendingRestoreFile = null;
newBranchCommit = null; newBranchCommit = null;
globalSearchResults = []; globalSearchResults = [];
deleteBranchTarget = null;
deleteBranchForce = false;
globalSearchOpen = false; globalSearchOpen = false;
globalSearchError = ""; globalSearchError = "";
resolveDialogOpen = false; resolveDialogOpen = false;
@@ -812,6 +817,11 @@
return (value?.files ?? []).some((file) => file.staged === "conflicted" || file.unstaged === "conflicted"); 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>) { async function runOperation(label: string, task: () => Promise<void>) {
if (isBusy) return; if (isBusy) return;
operation = label; operation = label;
@@ -1084,16 +1094,41 @@
return; return;
} }
const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`); deleteBranchTarget = branch;
if (!confirmed) return; deleteBranchForce = false;
}
await runOperation(`Deleting ${branch.name}`, async () => { async function confirmDeleteBranch() {
applyStatus(await deleteBranch(activeRepoPath, branch.name)); 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 refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(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) { function openNewBranchDialog(commit: GitCommit) {
@@ -1881,6 +1916,7 @@
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog(); else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = 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" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
} }
@@ -2374,6 +2410,17 @@
/> />
{/if} {/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 --> <!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen} {#if aiSettingsOpen}
<AiSettingsDialog <AiSettingsDialog
+17
View File
@@ -1679,6 +1679,12 @@
color: var(--color-ink); color: var(--color-ink);
} }
.branch-context-menu .menu-separator {
height: 1px;
margin: 4px 3px;
background: var(--color-border-subtle);
}
.branch-context-menu button.danger { .branch-context-menu button.danger {
color: #ff9aa8; color: #ff9aa8;
} }
@@ -2645,6 +2651,12 @@
gap: 14px; gap: 14px;
padding: 18px 16px 16px; 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 { .discard-warning-icon {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -2679,6 +2691,11 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.branch-delete-body .discard-target {
display: flex;
align-items: center;
gap: 6px;
}
.discard-warning-text { .discard-warning-text {
color: #ffb8bf; color: #ffb8bf;
font-weight: 650; 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) { function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isBusy || branch.remote) return; if (isBusy) return;
const rect = panelElement?.getBoundingClientRect(); const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX; const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY; const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192); 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; contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX)); contextMenuX = Math.max(8, Math.min(rawX, maxX));
@@ -244,11 +244,32 @@
async function deleteContextBranch() { async function deleteContextBranch() {
const branch = contextBranch; const branch = contextBranch;
if (!branch || branch.current || isBusy) return; if (!branch || branch.current || branch.remote || isBusy) return;
closeBranchContextMenu(); closeBranchContextMenu();
await onDeleteBranch(branch); 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) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu(); if (event.key === "Escape") closeBranchContextMenu();
} }
@@ -363,20 +384,6 @@
</div> </div>
{#if row.branch.current} {#if row.branch.current}
<span class="pill pill-active">Current</span> <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} {/if}
</article> </article>
{/if} {/if}
@@ -432,6 +439,7 @@
class:current={row.branch.current} class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`} style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)} ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name} title={row.branch.current ? "Current branch" : row.branch.name}
> >
<div class="branch-info"> <div class="branch-info">
@@ -443,20 +451,6 @@
</div> </div>
{#if row.branch.current} {#if row.branch.current}
<span class="pill pill-active">Current</span> <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} {/if}
</article> </article>
{/if} {/if}
@@ -475,7 +469,20 @@
tabindex="-1" tabindex="-1"
aria-label={`Actions for ${contextBranch.name}`} 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" /> <Pencil size={14} aria-hidden="true" />
Rename Rename
</button> </button>
@@ -484,8 +491,8 @@
type="button" type="button"
role="menuitem" role="menuitem"
onclick={deleteContextBranch} onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current} disabled={isBusy || contextBranch.current || contextBranch.remote}
title={contextBranch.current ? "Current branch cannot be deleted" : "Delete local branch"} 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" /> <Trash2 size={14} aria-hidden="true" />
Delete Delete
+2 -2
View File
@@ -72,8 +72,8 @@ export function renameBranch(
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch }); return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
} }
export function deleteBranch(path: string, branch: string): Promise<GitStatus> { export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch }); return invoke<GitStatus>("delete_branch", { path, branch, force });
} }
export function stageFiles(path: string, files: string[]): Promise<GitStatus> { export function stageFiles(path: string, files: string[]): Promise<GitStatus> {