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"} >