Offer always-delete option for preserved branches (#29505)

* Offer always-delete choice for preserved branches

* Fix repository-wide lint blockers

* Refresh CI contracts for runtime and draft workflows
This commit is contained in:
Neil
2026-10-16 00:36:37 -07:00
committed by GitHub
parent 4416a8e301
commit b603137f15
13 changed files with 317 additions and 48 deletions
+17
View File
@@ -258,6 +258,23 @@ describe('registerSettingsHandlers', () => {
)
})
it('flushes the preserved-branch deletion preference before returning', async () => {
store.getSettings.mockReturnValue({ alwaysForceDeletePreservedBranches: false })
store.updateSettingsAndFlush.mockRejectedValue(new Error('Disk full'))
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1]
if (typeof handler !== 'function') {
throw new Error('Missing settings:set handler')
}
await expect(
handler(settingsInvokeEvent, { alwaysForceDeletePreservedBranches: true })
).rejects.toThrow('Disk full')
expect(store.updateSettingsAndFlush).toHaveBeenCalledWith(
{ alwaysForceDeletePreservedBranches: true },
{ notifyListeners: true, originWebContentsId: 1 }
)
})
it('persists Active Server only through the dedicated preference channel', () => {
store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'windows-2' })
registerSettingsHandlers(store as never)
+2 -1
View File
@@ -205,7 +205,8 @@ export function registerSettingsHandlers(
originWebContentsId: event.sender.id
}
const result =
'alwaysForceDeleteWorktrees' in sanitizedArgs
'alwaysForceDeleteWorktrees' in sanitizedArgs ||
'alwaysForceDeletePreservedBranches' in sanitizedArgs
? await store.updateSettingsAndFlush(sanitizedArgs, updateOptions)
: store.updateSettings(sanitizedArgs, updateOptions)
const proxySettingsChanged =
@@ -20,6 +20,11 @@ export function WorktreeForceDeleteButton({
size = 'sm',
disabled = false,
buttonRef,
fullWidth = false,
menuSide,
moreActionsLabel,
alwaysForceDeleteLabel,
alwaysForceDeleteTooltip,
children
}: {
toastId?: string
@@ -29,6 +34,11 @@ export function WorktreeForceDeleteButton({
size?: 'sm' | 'default'
disabled?: boolean
buttonRef?: Ref<HTMLButtonElement>
fullWidth?: boolean
menuSide?: 'top' | 'bottom'
moreActionsLabel?: string
alwaysForceDeleteLabel?: string
alwaysForceDeleteTooltip?: string
children?: ReactNode
}): React.JSX.Element {
const [isSaving, setIsSaving] = useState(false)
@@ -62,6 +72,7 @@ export function WorktreeForceDeleteButton({
return (
<DropdownMenu modal={false}>
<ButtonGroup
className={fullWidth ? 'w-full' : undefined}
onBlur={(event) => {
if (
event.relatedTarget instanceof HTMLElement &&
@@ -77,6 +88,7 @@ export function WorktreeForceDeleteButton({
variant="destructive"
size={size}
disabled={disabled || isSaving}
className={fullWidth ? 'min-w-0 flex-1' : undefined}
onClick={forceDelete}
>
{children ??
@@ -89,7 +101,10 @@ export function WorktreeForceDeleteButton({
variant="destructive"
size={size === 'sm' ? 'icon-sm' : 'icon'}
disabled={disabled || isSaving}
aria-label={translate('workspaceDeletion.moreActions', 'More force delete options')}
aria-label={
moreActionsLabel ??
translate('workspaceDeletion.moreActions', 'More force delete options')
}
>
<ChevronDown className="size-3.5" />
</Button>
@@ -99,6 +114,7 @@ export function WorktreeForceDeleteButton({
{onAlwaysForceDelete ? (
<DropdownMenuContent
align="end"
side={menuSide}
// Sonner restores toast focus on blur; the portaled menu owns its focus.
onFocus={(event) => event.stopPropagation()}
onBlur={(event) => event.stopPropagation()}
@@ -111,14 +127,16 @@ export function WorktreeForceDeleteButton({
void alwaysForceDelete()
}}
>
{translate('workspaceDeletion.alwaysForceDelete', 'Always force delete')}
{alwaysForceDeleteLabel ??
translate('workspaceDeletion.alwaysForceDelete', 'Always force delete')}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>
{translate(
'workspaceDeletion.alwaysForceDeleteTooltip',
'Applies to all future workspace deletions.'
)}
{alwaysForceDeleteTooltip ??
translate(
'workspaceDeletion.alwaysForceDeleteTooltip',
'Applies to all future workspace deletions.'
)}
</TooltipContent>
</Tooltip>
</DropdownMenuContent>
@@ -4,13 +4,15 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { TooltipProvider } from '../ui/tooltip'
import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types'
import { showPreservedBranchToast } from './preserved-branch-toast'
vi.mock('sonner', () => ({
toast: {
warning: vi.fn(),
dismiss: vi.fn()
dismiss: vi.fn(),
error: vi.fn()
}
}))
@@ -24,7 +26,7 @@ function renderToastBody(): HTMLElement {
const root = createRoot(container)
mountedRoots.push(root)
act(() => {
root.render(description)
root.render(<TooltipProvider>{description}</TooltipProvider>)
})
return container
}
@@ -102,4 +104,55 @@ describe('showPreservedBranchToast', () => {
expect.not.objectContaining({ duration: Infinity })
)
})
it('saves the future branch choice before deleting the current branch', async () => {
const savePreference = vi.fn().mockResolvedValue(undefined)
const onForceDelete = vi.fn()
showPreservedBranchToast(
{ preservedBranch: { branchName: 'feature/test', head: 'abc123' } },
undefined,
onForceDelete,
savePreference
)
const body = renderToastBody()
const trigger = body.querySelector('button[aria-label="More branch delete options"]')
expect(trigger).not.toBeNull()
await act(async () => {
trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
})
const option = document.querySelector('[role="menuitem"]')
expect(option?.textContent).toBe('Always delete preserved branches')
await act(async () => {
option?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(savePreference).toHaveBeenCalledOnce()
expect(onForceDelete).toHaveBeenCalledWith('feature/test', 'abc123')
})
it('keeps the branch and toast when saving the future choice fails', async () => {
const savePreference = vi.fn().mockRejectedValue(new Error('disk full'))
const onForceDelete = vi.fn()
showPreservedBranchToast(
{ preservedBranch: { branchName: 'feature/test', head: 'abc123' } },
undefined,
onForceDelete,
savePreference
)
const body = renderToastBody()
await act(async () => {
body
.querySelector('button[aria-label="More branch delete options"]')
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
})
await act(async () => {
document
.querySelector('[role="menuitem"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onForceDelete).not.toHaveBeenCalled()
expect(toast.dismiss).not.toHaveBeenCalled()
})
})
@@ -3,7 +3,7 @@ import { toast } from 'sonner'
import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import { WorktreeForceDeleteButton } from './WorktreeForceDeleteButton'
type PreservedBranchWorktree = Pick<Worktree, 'displayName' | 'isMainWorktree'>
@@ -11,6 +11,7 @@ type PreservedBranchToastBodyProps = {
description: string
forceDeleteLabel: string | undefined
onForceDelete: (() => void) | undefined
onAlwaysForceDelete: (() => Promise<void>) | undefined
}
function preservedBranchToastId(branchName: string, expectedHead: string | undefined): string {
@@ -53,7 +54,8 @@ function getPreservedBranchDescription(
function PreservedBranchToastBody({
description,
forceDeleteLabel,
onForceDelete
onForceDelete,
onAlwaysForceDelete
}: PreservedBranchToastBodyProps): React.JSX.Element {
return (
<div className="flex w-[300px] max-w-[calc(100vw-96px)] flex-col gap-3">
@@ -61,17 +63,28 @@ function PreservedBranchToastBody({
{description}
</p>
{forceDeleteLabel && onForceDelete ? (
<div className="flex min-w-0 overflow-hidden">
<Button
type="button"
variant="destructive"
size="sm"
className="w-full min-w-0"
onClick={onForceDelete}
<div className="min-w-0">
<WorktreeForceDeleteButton
fullWidth
menuSide="top"
onForceDelete={onForceDelete}
onAlwaysForceDelete={onAlwaysForceDelete}
moreActionsLabel={translate(
'workspaceDeletion.moreBranchActions',
'More branch delete options'
)}
alwaysForceDeleteLabel={translate(
'workspaceDeletion.alwaysForceDeleteBranches',
'Always delete preserved branches'
)}
alwaysForceDeleteTooltip={translate(
'workspaceDeletion.alwaysForceDeleteBranchesTooltip',
'Applies to branches kept after future workspace deletions.'
)}
>
<Trash2 className="size-3.5" />
<span className="truncate">{forceDeleteLabel}</span>
</Button>
</WorktreeForceDeleteButton>
</div>
) : null}
</div>
@@ -81,7 +94,8 @@ function PreservedBranchToastBody({
export function showPreservedBranchToast(
result: RemoveWorktreeResult | undefined,
worktree: PreservedBranchWorktree | undefined,
onForceDelete: (branchName: string, expectedHead: string) => void
onForceDelete: (branchName: string, expectedHead: string) => void,
onAlwaysForceDelete?: () => Promise<void>
): void {
const preservedBranch = result?.preservedBranch
const branch = preservedBranch?.branchName
@@ -111,6 +125,7 @@ export function showPreservedBranchToast(
description={description}
forceDeleteLabel={forceDeleteLabel}
onForceDelete={forceDelete}
onAlwaysForceDelete={onAlwaysForceDelete}
/>
),
dismissible: true,
+4 -1
View File
@@ -19925,7 +19925,10 @@
"preferenceSaveFailed": "Could not save deletion preference",
"alwaysForceDelete": "Always force delete",
"moreActions": "More force delete options",
"alwaysForceDeleteTooltip": "Applies to all future workspace deletions."
"alwaysForceDeleteTooltip": "Applies to all future workspace deletions.",
"moreBranchActions": "More branch delete options",
"alwaysForceDeleteBranches": "Always delete preserved branches",
"alwaysForceDeleteBranchesTooltip": "Applies to branches kept after future workspace deletions."
},
"worktree": {
"nestedRemoval": {
@@ -115,3 +115,90 @@ it('keeps forgetting a disconnected workspace metadata-only when the preference
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('deletes a preserved branch with its saved head when the branch preference is enabled', async () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1' })
store.setState({
settings: {
...getDefaultSettings('/home/test'),
alwaysForceDeletePreservedBranches: true
},
worktreesByRepo: { [wt.repoId]: [wt] }
})
mockApi.worktrees.remove.mockResolvedValueOnce({
preservedBranch: { branchName: 'feature/test', head: 'abc123' }
})
expect(await store.getState().removeWorktree({ id: wt.id, executionHostId: null })).toEqual({
ok: true
})
expect(mockApi.worktrees.forceDeletePreservedBranch).toHaveBeenCalledWith({
worktreeId: wt.id,
branchName: 'feature/test',
expectedHead: 'abc123',
hostId: 'local'
})
})
it('keeps a recoverable branch when automatic deletion fails', async () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1' })
store.setState({
settings: {
...getDefaultSettings('/home/test'),
alwaysForceDeletePreservedBranches: true
},
worktreesByRepo: { [wt.repoId]: [wt] }
})
mockApi.worktrees.remove.mockResolvedValueOnce({
preservedBranch: { branchName: 'feature/test', head: 'abc123' }
})
mockApi.worktrees.forceDeletePreservedBranch.mockRejectedValueOnce(new Error('branch moved'))
expect(await store.getState().removeWorktree({ id: wt.id, executionHostId: null })).toEqual({
ok: true,
preservedBranch: { branchName: 'feature/test', head: 'abc123', hostId: 'local' }
})
})
it('routes automatic branch deletion through the owner runtime', async () => {
const store = createTestStore()
const wt = makeWorktree({
id: 'repo1::/path/wt1',
repoId: 'repo1',
hostId: 'runtime:env-1',
runtimeOwnerEnvironmentId: 'env-1'
})
store.setState({
settings: {
...getDefaultSettings('/home/test'),
alwaysForceDeletePreservedBranches: true
},
worktreesByRepo: { [wt.repoId]: [wt] }
})
runtimeEnvironmentCall.mockImplementation(({ method }) => ({
id: 'rpc-removal',
ok: true,
result:
method === 'worktree.rm'
? { preservedBranch: { branchName: 'feature/remote', head: 'abc123' } }
: { deleted: true },
_meta: { runtimeId: 'runtime-remote' }
}))
expect(
await store.getState().removeWorktree({ id: wt.id, executionHostId: 'runtime:env-1' })
).toEqual({ ok: true })
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'env-1',
method: 'worktree.forceDeleteBranch',
params: expect.objectContaining({
worktree: `id:${wt.id}`,
branchName: 'feature/remote',
expectedHead: 'abc123'
})
})
)
})
@@ -0,0 +1,53 @@
import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { WorktreeSlice } from '../../worktree-helpers'
import type { WorktreeSliceGet } from '../listing/worktree-slice-types'
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
export async function completePreservedBranchCleanup({
get,
worktreeId,
removalResult,
worktreeBeforeRemoval,
branchCleanupOptions,
suppressToast
}: {
get: WorktreeSliceGet
worktreeId: string
removalResult: RemoveWorktreeResult | undefined
worktreeBeforeRemoval: Pick<Worktree, 'displayName' | 'isMainWorktree'> | undefined
branchCleanupOptions: NonNullable<Parameters<WorktreeSlice['forceDeletePreservedBranch']>[3]>
suppressToast: boolean
}): Promise<boolean> {
const preservedBranch = removalResult?.preservedBranch
if (!preservedBranch) {
return false
}
const autoDeleted =
preservedBranch.head && get().settings?.alwaysForceDeletePreservedBranches === true
? (
await get().forceDeletePreservedBranch(
worktreeId,
preservedBranch.branchName,
preservedBranch.head,
{ ...branchCleanupOptions, suppressToast: true }
)
).ok
: false
if (!autoDeleted && !suppressToast) {
showPreservedBranchToast(
removalResult,
worktreeBeforeRemoval,
(branch, expectedHead) => {
void get().forceDeletePreservedBranch(
worktreeId,
branch,
expectedHead,
branchCleanupOptions
)
},
() => get().updateSettingsOrThrow({ alwaysForceDeletePreservedBranches: true })
)
}
return autoDeleted
}
@@ -7,6 +7,7 @@
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types'
import type { WorktreeSlice } from '../../worktree-helpers'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import {
@@ -25,7 +26,7 @@ import {
import { translate } from '@/i18n/i18n'
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
import { purgeOrphanedRuntimeSshProjects } from './orphaned-runtime-ssh-project-purge'
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
import { completePreservedBranchCleanup } from './complete-preserved-branch-cleanup'
import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup'
import { preservedBranchRuntimeTargetByCleanupKey } from './preserved-branch-cleanup-target'
@@ -39,7 +40,7 @@ import {
export { prepareHostScopedRemovalCompletion, preservesSameIdRendererState }
type PreservedBranchWorktree = Parameters<typeof showPreservedBranchToast>[1]
type PreservedBranchWorktree = Pick<Worktree, 'displayName' | 'isMainWorktree'> | undefined
type RemoveWorktreeSliceResult = Awaited<ReturnType<WorktreeSlice['removeWorktree']>>
/** Route at the confirmed host when there is one, else the ordinary active-host route. */
@@ -251,16 +252,25 @@ export async function completeSameIdHostScopedRemoval(args: {
cleanup,
target
})
if (!suppressPreservedBranchToast) {
showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => {
void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, {
hostId: requiredExecutionHostId,
...runtimeEnvironment
})
})
}
const branchCleanupOptions = { hostId: requiredExecutionHostId, ...runtimeEnvironment }
const autoDeletedBranch = await completePreservedBranchCleanup({
get,
worktreeId,
removalResult,
worktreeBeforeRemoval,
branchCleanupOptions,
suppressToast: suppressPreservedBranchToast
})
return {
ok: true as const,
preservedBranch: { ...preservedBranch, hostId: requiredExecutionHostId, ...runtimeEnvironment }
...(!autoDeletedBranch
? {
preservedBranch: {
...preservedBranch,
hostId: requiredExecutionHostId,
...runtimeEnvironment
}
}
: {})
}
}
@@ -9,7 +9,6 @@ import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-contro
import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
import { readIpcErrorDetail } from '@/lib/ipc-error'
import { isArchiveHookRemovalError } from '../../../../../../shared/worktree/archive-hook-removal-gate'
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
import {
resolveWorktreeOperationRouteResult,
resolveWorktreeOperationRouteResultForHost
@@ -42,6 +41,7 @@ import { dispatchWorktreeRemoval } from './dispatch-worktree-removal'
import { tearDownRemovedWorktreeRendererState } from './removed-worktree-renderer-teardown'
import { captureWorktreeStateBeforeRemoval } from './worktree-state-before-removal'
import { resolveWorktreeRemovalPreference } from './worktree-removal-preference'
import { completePreservedBranchCleanup } from './complete-preserved-branch-cleanup'
export function createRemoveWorktree(
set: WorktreeSliceSet,
@@ -268,18 +268,22 @@ export function createRemoveWorktree(
target
})
}
if (preservedBranch && options?.suppressPreservedBranchToast !== true) {
showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => {
void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, {
...(hostId ? { hostId } : {}),
...(removalRoute?.runtimeEnvironmentId
? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId }
: {})
})
})
const branchCleanupOptions = {
...(hostId ? { hostId } : {}),
...(removalRoute?.runtimeEnvironmentId
? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId }
: {})
}
const autoDeletedBranch = await completePreservedBranchCleanup({
get,
worktreeId,
removalResult,
worktreeBeforeRemoval,
branchCleanupOptions,
suppressToast: options?.suppressPreservedBranchToast === true
})
pruneHostedReviewLinkMutationGenerations([worktreeId])
return preservedBranch && cleanup
return preservedBranch && cleanup && !autoDeletedBranch
? {
ok: true as const,
preservedBranch: {
+1
View File
@@ -203,6 +203,7 @@ export function buildDefaultSettings(args: {
claudeAgentTeamsDefaultDisabledMigrated: true,
skipDeleteWorktreeConfirm: false,
alwaysForceDeleteWorktrees: false,
alwaysForceDeletePreservedBranches: false,
skipCloseTerminalWithRunningProcessConfirm: false,
skipDeleteAutomationConfirm: false,
skipDeleteArtifactConfirm: false,
+4 -5
View File
@@ -1,4 +1,5 @@
import type { NativeChatGlobalSettings } from './native-chat-appearance-settings'
import type { WorktreeDeletionPreferences as DeletePrefs } from './worktree-deletion-preferences'
import type { ExecutionHostId } from './execution-host'
import type { OrcaWorkspaceLayout } from './orca-workspace-layout'
import type { GitHubProjectSettings } from './github/project-types'
@@ -49,7 +50,9 @@ export type { WorktreeVisibilityDefaults } from './repo-types'
/** MiniMax account region used to select the quota endpoint. */
export type MiniMaxEndpoint = 'overseas' | 'cn'
export type GlobalSettings = NativeChatGlobalSettings & {
type GlobalSettingsBase = NativeChatGlobalSettings & DeletePrefs
export type GlobalSettings = GlobalSettingsBase & {
workspaceDir: string
/** Host-owned defaults used when a repository has no explicit visibility override. */
worktreeVisibilityDefaults?: WorktreeVisibilityDefaults
@@ -366,10 +369,6 @@ export type GlobalSettings = NativeChatGlobalSettings & {
devPluginPaths: string[]
/** One-shot guard: start Claude Agent Teams hidden for existing profiles without overriding later opt-ins. */
claudeAgentTeamsDefaultDisabledMigrated?: boolean
/** Why: worktree deletion is destructive (rm -rf of the working dir), so confirm by default. */
skipDeleteWorktreeConfirm: boolean
/** Opt-in: workspace deletion discards changes and may waive terminal-stop verification. */
alwaysForceDeleteWorktrees?: boolean
/** Why: closing a terminal with child processes kills foreground work; keep this skip separate from other confirmations. */
skipCloseTerminalWithRunningProcessConfirm: boolean
/** Why: deleting an automation also deletes its run history; keep this skip separate from worktree deletion. */
@@ -0,0 +1,8 @@
export type WorktreeDeletionPreferences = {
/** Worktree deletion removes the working directory, so confirmation is on by default. */
skipDeleteWorktreeConfirm: boolean
/** Opt-in: workspace deletion discards changes and may waive terminal-stop verification. */
alwaysForceDeleteWorktrees?: boolean
/** Opt-in: delete preserved local branches after workspace removal. */
alwaysForceDeletePreservedBranches?: boolean
}