Add saved force-delete preference to workspace deletion menu (#26425)

* Give Windows Electron writer probes a larger test budget

* Drain queued writer replies across two poll turns before timing out

* Check writer shutdown after both reply-drain turns

* Add inline always-force-delete preference to workspace recovery toast

* Use split-button menu for saved force-delete preference

* Await durable force-delete preference writes before deleting

* Restore absent settings keys after failed durable writes

* Place deletion translations beside worktree copy
This commit is contained in:
Neil
2026-10-07 23:42:11 -07:00
committed by GitHub
parent 69900f0232
commit a211489230
15 changed files with 604 additions and 21 deletions
+19
View File
@@ -99,6 +99,7 @@ type SettingsChangedListener = (
const store = {
getSettings: vi.fn(),
updateSettings: vi.fn(),
updateSettingsAndFlush: vi.fn(),
getGitHubCache: vi.fn(),
setGitHubCache: vi.fn(),
onSettingsChanged: vi.fn(() => () => {})
@@ -107,6 +108,7 @@ const store = {
describe('registerSettingsHandlers', () => {
beforeEach(() => {
handleMock.mockClear()
store.updateSettingsAndFlush.mockReset()
onMock.mockClear()
applyAppIconMock.mockClear()
applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([])
@@ -239,6 +241,23 @@ describe('registerSettingsHandlers', () => {
)
})
it('propagates a failed durable force-delete preference write through settings:set', async () => {
store.getSettings.mockReturnValue({ alwaysForceDeleteWorktrees: 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, { alwaysForceDeleteWorktrees: true, editorWordWrap: false })
).rejects.toThrow('Disk full')
expect(store.updateSettingsAndFlush).toHaveBeenCalledWith(
{ alwaysForceDeleteWorktrees: true, editorWordWrap: false },
{ notifyListeners: true, originWebContentsId: 1 }
)
})
it('persists Active Server only through the dedicated preference channel', () => {
store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'windows-2' })
registerSettingsHandlers(store as never)
+6 -2
View File
@@ -199,10 +199,14 @@ export function registerSettingsHandlers(
// (e.g. blur after a no-op edit), and a `settings_changed` event for a
// no-op flip would inflate the experimental-feature-adoption signal.
const before = store.getSettings()
const result = store.updateSettings(sanitizedArgs, {
const updateOptions = {
notifyListeners: true,
originWebContentsId: event.sender.id
})
}
const result =
'alwaysForceDeleteWorktrees' in sanitizedArgs
? await store.updateSettingsAndFlush(sanitizedArgs, updateOptions)
: store.updateSettings(sanitizedArgs, updateOptions)
const proxySettingsChanged =
('httpProxyUrl' in sanitizedArgs && before.httpProxyUrl !== result.httpProxyUrl) ||
('httpProxyBypassRules' in sanitizedArgs &&
@@ -0,0 +1,96 @@
import { expect, it, vi } from 'vitest'
import {
createWorkerMaintenanceFixture,
maintenanceBarrier
} from './profile-state-maintenance-fixture'
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../telemetry/cohort-classifier', () => ({
getCohortAtEmit: () => ({ nth_repo_added: 2 })
}))
vi.mock('../../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
it('waits for the SQLite write before returning or publishing the preference', async () => {
const { store, authority, readState } = await createWorkerMaintenanceFixture()
const started = maintenanceBarrier()
const release = maintenanceBarrier()
const write = authority.writeSerializedDomains.bind(authority)
vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => {
started.resolve()
await release.promise
await write(domains)
})
const onChanged = vi.fn()
store.onSettingsChanged(onChanged)
let settled = false
const pending = store.updateSettingsAndFlush(
{ alwaysForceDeleteWorktrees: true },
{ notifyListeners: true, originWebContentsId: 7 }
)
void pending.then(() => {
settled = true
})
await started.promise
expect(settled).toBe(false)
expect(onChanged).not.toHaveBeenCalled()
store.updateSettings({ theme: 'dark' })
release.resolve()
expect((await pending).theme).toBe('dark')
expect(readState().settings.alwaysForceDeleteWorktrees).toBe(true)
expect(onChanged).toHaveBeenCalledWith(
{ alwaysForceDeleteWorktrees: true },
expect.objectContaining({ alwaysForceDeleteWorktrees: true }),
7
)
})
it('rejects a failed disk write and rolls back without publishing the preference', async () => {
const { store, authority, readState } = await createWorkerMaintenanceFixture()
const previous = store.getSettings().alwaysForceDeleteWorktrees
vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('Disk full'))
const onChanged = vi.fn()
store.onSettingsChanged(onChanged)
await expect(
store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true }, { notifyListeners: true })
).rejects.toThrow('Disk full')
expect(store.getSettings().alwaysForceDeleteWorktrees).toBe(previous)
expect(readState().settings.alwaysForceDeleteWorktrees).toBe(previous)
expect(onChanged).not.toHaveBeenCalled()
})
it('rolls back the preference while retaining unrelated concurrent settings edits', async () => {
const { store, authority } = await createWorkerMaintenanceFixture()
const previous = store.getSettings().alwaysForceDeleteWorktrees
const started = maintenanceBarrier()
const release = maintenanceBarrier()
vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => {
started.resolve()
await release.promise
throw new Error('Disk full')
})
const pending = store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true })
const rejected = expect(pending).rejects.toThrow('Disk full')
await started.promise
store.updateSettings({ theme: 'dark' })
release.resolve()
await rejected
expect(store.getSettings()).toMatchObject({
alwaysForceDeleteWorktrees: previous,
theme: 'dark'
})
})
it('removes previously absent optional keys after a failed preference payload write', async () => {
const { store, authority } = await createWorkerMaintenanceFixture()
delete store.getSettings().editorWordWrap
const previous = store.getSettings().alwaysForceDeleteWorktrees
vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('Disk full'))
await expect(
store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true, editorWordWrap: false })
).rejects.toThrow('Disk full')
expect(Object.hasOwn(store.getSettings(), 'editorWordWrap')).toBe(false)
expect(store.getSettings().alwaysForceDeleteWorktrees).toBe(previous)
})
@@ -25,6 +25,7 @@ type ProfilePreferencesRuntime = Pick<
| 'githubCacheDirty'
| 'githubCacheGeneration'
| 'protectedSecrets'
| 'runDurableMutation'
| 'settingsChangeListeners'
| 'state'
| 'uiChangeListeners'
@@ -74,6 +75,52 @@ export class ProfilePreferences {
return updateSettingsOperation(getSettingsMutationOperations(this), updates, options)
}
async updateSettingsAndFlush(
updates: Partial<GlobalSettings>,
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
): Promise<GlobalSettings> {
const { runtime } = this[profilePreferencesContext]
let changedUpdates: Partial<GlobalSettings> = {}
await runtime.runDurableMutation(() => {
const previous = runtime.state.settings
const next = this.updateSettings(updates)
const previousEntries = new Map(Object.entries(previous))
const updateKeys = new Set(Object.keys(updates))
changedUpdates = Object.fromEntries(
Object.entries(next).filter(
([key, value]) => updateKeys.has(key) && !Object.is(previousEntries.get(key), value)
)
)
return {
value: undefined,
rollback: () => {
const currentEntries = new Map(Object.entries(runtime.state.settings))
const nextEntries = new Map(Object.entries(next))
const restoredUpdates = Object.fromEntries(
Object.entries(previous).filter(
([key]) =>
updateKeys.has(key) && Object.is(currentEntries.get(key), nextEntries.get(key))
)
)
const restoredSettings = { ...runtime.state.settings, ...restoredUpdates }
for (const key of updateKeys) {
if (
!previousEntries.has(key) &&
Object.is(currentEntries.get(key), nextEntries.get(key))
) {
Reflect.deleteProperty(restoredSettings, key)
}
}
runtime.state.settings = restoredSettings
}
}
})
if (options.notifyListeners && Object.keys(changedUpdates).length > 0) {
notifySettingsChanged(this, changedUpdates, options.originWebContentsId)
}
return this.getSettings()
}
getUI(): PersistedState['ui'] {
return getPersistedUI(
this[profilePreferencesContext].runtime.state,
+1 -1
View File
@@ -101,8 +101,8 @@ function App(): React.JSX.Element {
<BrowserWebAuthnAccountDialog />
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
<SkillFreshnessNudge />
<WorktreeBaseFallbackDialog />
<PinnedTabCloseDialog />
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import { ButtonGroup } from '../ui/button-group'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
export function WorktreeForceDeleteButton({
toastId,
onForceDelete,
onAlwaysForceDelete
}: {
toastId: string
onForceDelete: () => void
onAlwaysForceDelete?: () => Promise<void>
}): React.JSX.Element {
const [isSaving, setIsSaving] = useState(false)
const forceDelete = (): void => {
toast.dismiss(toastId)
onForceDelete()
}
const alwaysForceDelete = async (): Promise<void> => {
if (!onAlwaysForceDelete) {
return
}
setIsSaving(true)
try {
await onAlwaysForceDelete()
} catch (error) {
setIsSaving(false)
toast.error(
translate('workspaceDeletion.preferenceSaveFailed', 'Could not save deletion preference'),
{ description: error instanceof Error ? error.message : String(error) }
)
return
}
forceDelete()
}
return (
<DropdownMenu modal={false}>
<ButtonGroup
onBlur={(event) => {
if (
event.relatedTarget instanceof HTMLElement &&
event.relatedTarget.closest('[data-radix-menu-content]')
) {
event.stopPropagation()
}
}}
>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isSaving}
onClick={forceDelete}
>
{translate('auto.components.sidebar.delete.worktree.flow.2b20ce87b3', 'Force Delete')}
</Button>
{onAlwaysForceDelete ? (
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="destructive"
size="icon-sm"
disabled={isSaving}
aria-label={translate('workspaceDeletion.moreActions', 'More force delete options')}
>
<ChevronDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
) : null}
</ButtonGroup>
{onAlwaysForceDelete ? (
<DropdownMenuContent
align="end"
// Sonner restores toast focus on blur; the portaled menu owns its focus.
onFocus={(event) => event.stopPropagation()}
onBlur={(event) => event.stopPropagation()}
>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void alwaysForceDelete()
}}
>
{translate('workspaceDeletion.alwaysForceDelete', 'Always force delete')}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>
{translate(
'workspaceDeletion.alwaysForceDeleteTooltip',
'Applies to all future workspace deletions.'
)}
</TooltipContent>
</Tooltip>
</DropdownMenuContent>
) : null}
</DropdownMenu>
)
}
@@ -0,0 +1,153 @@
// @vitest-environment happy-dom
import { act, isValidElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { TooltipProvider } from '../ui/tooltip'
import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn(), dismiss: vi.fn() } }))
let root: Root | undefined
function renderRecovery(onAlwaysForceDelete: () => Promise<void>, canForceDelete = true) {
const onForceDelete = vi.fn()
const onOutsideBlur = vi.fn()
showDeleteWorktreeFailureToast({
error: 'branch has changes',
canForceDelete,
forceDeleteReason: canForceDelete ? 'dirty' : null,
onViewChanges: vi.fn(),
onForceDelete,
onAlwaysForceDelete,
onDeleteAnyway: vi.fn(),
worktreeId: 'wt-1',
worktreeName: 'feature/foo'
})
const description = vi
.mocked(canForceDelete ? toast.info : toast.error)
.mock.calls.at(-1)?.[1]?.description
if (!isValidElement(description)) {
throw new Error('Expected a rendered recovery toast')
}
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() =>
root?.render(
<TooltipProvider delayDuration={0}>
<div
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
onOutsideBlur()
}
}}
>
{description}
</div>
</TooltipProvider>
)
)
return { container, onForceDelete, onOutsideBlur }
}
function click(container: HTMLElement, selector: string): void {
const element = container.querySelector(selector)
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing ${selector}`)
}
element.click()
}
async function selectAlwaysForceDelete(container: HTMLElement): Promise<void> {
await act(async () => {
const trigger = container.querySelector('button[aria-label="More force delete options"]')
if (!(trigger instanceof HTMLElement)) {
throw new Error('Missing dropdown trigger')
}
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
})
await act(async () => click(document.body, '[role="menuitem"]'))
}
afterEach(() => {
act(() => root?.unmount())
root = undefined
document.body.innerHTML = ''
vi.clearAllMocks()
})
it('keeps the main Force Delete button a one-time action', async () => {
const savePreference = vi.fn().mockResolvedValue(undefined)
const { container, onForceDelete } = renderRecovery(savePreference)
expect(container.querySelector('button[aria-label="More force delete options"]')).not.toBeNull()
expect(document.querySelector('[role="menuitem"]')).toBeNull()
await act(async () => click(container, 'button[data-variant="destructive"]'))
expect(savePreference).not.toHaveBeenCalled()
expect(onForceDelete).toHaveBeenCalledOnce()
})
it('keeps portaled menu focus from triggering toast focus restoration', async () => {
const { container, onOutsideBlur } = renderRecovery(vi.fn())
const trigger = container.querySelector('button[aria-label="More force delete options"]')
if (!(trigger instanceof HTMLElement)) {
throw new Error('Missing dropdown trigger')
}
await act(async () => {
trigger.focus()
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
})
const item = document.querySelector('[role="menuitem"]')
if (!(item instanceof HTMLElement)) {
throw new Error('Missing preference menu item')
}
await act(async () => item.focus())
expect(onOutsideBlur).not.toHaveBeenCalled()
expect(document.querySelector('[role="menu"]')).not.toBeNull()
})
it('waits for the preference to persist before force-deleting and dismissing', async () => {
let finishSaving: (() => void) | undefined
const savePreference = vi.fn(
() =>
new Promise<void>((resolve) => {
finishSaving = resolve
})
)
const { container, onForceDelete } = renderRecovery(savePreference)
await selectAlwaysForceDelete(container)
expect(savePreference).toHaveBeenCalledOnce()
expect(
container.querySelector('button[data-variant="destructive"]')?.hasAttribute('disabled')
).toBe(true)
expect(onForceDelete).not.toHaveBeenCalled()
expect(toast.dismiss).not.toHaveBeenCalled()
await act(async () => finishSaving?.())
expect(onForceDelete).toHaveBeenCalledOnce()
expect(toast.dismiss).toHaveBeenCalledWith('delete-worktree-failure:wt-1')
})
it('keeps recovery available when saving the preference fails', async () => {
const { container, onForceDelete } = renderRecovery(
vi.fn().mockRejectedValue(new Error('Disk full'))
)
await selectAlwaysForceDelete(container)
expect(onForceDelete).not.toHaveBeenCalled()
expect(toast.dismiss).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('Could not save deletion preference', {
description: 'Disk full'
})
expect(
container.querySelector('button[data-variant="destructive"]')?.hasAttribute('disabled')
).toBe(false)
})
it('does not offer the preference for a failure that cannot be force-deleted', () => {
const { container } = renderRecovery(vi.fn(), false)
expect(container.querySelector('button[aria-label="More force delete options"]')).toBeNull()
})
@@ -3,6 +3,7 @@ import { Button } from '../ui/button'
import { getDeleteWorktreeToastCopy } from './delete-worktree-toast'
import { translate } from '@/i18n/i18n'
import { DeleteNestedWorktreesDialog } from './DeleteNestedWorktreesDialog'
import { WorktreeForceDeleteButton } from './WorktreeForceDeleteButton'
import { isNestedWorktreeRemovalError } from '../../../../shared/worktree/nested-removal'
import {
isLockedWorktreeRemovalError,
@@ -20,6 +21,7 @@ type DeleteWorktreeFailureToastOptions = {
canWaiveArchiveHook?: boolean
onViewChanges: () => void
onForceDelete: () => void
onAlwaysForceDelete?: () => Promise<void>
onDeleteAnyway: () => void
worktreeId: string
worktreeName: string
@@ -38,6 +40,7 @@ function DeleteWorktreeFailureToastBody({
showViewChanges,
onViewChanges,
onForceDelete,
onAlwaysForceDelete,
onDeleteAnyway,
toastId,
nestedRemovalTarget,
@@ -50,6 +53,7 @@ function DeleteWorktreeFailureToastBody({
showViewChanges: boolean
onViewChanges: () => void
onForceDelete: () => void
onAlwaysForceDelete?: () => Promise<void>
onDeleteAnyway: () => void
toastId: string
nestedRemovalTarget?: WorktreeRemovalTarget
@@ -60,10 +64,6 @@ function DeleteWorktreeFailureToastBody({
toast.dismiss(toastId)
onViewChanges()
}
const forceDelete = (): void => {
toast.dismiss(toastId)
onForceDelete()
}
const deleteAnyway = (): void => {
toast.dismiss(toastId)
onDeleteAnyway()
@@ -89,9 +89,11 @@ function DeleteWorktreeFailureToastBody({
/>
) : null}
{canForceDelete ? (
<Button type="button" variant="destructive" size="sm" onClick={forceDelete}>
{translate('auto.components.sidebar.delete.worktree.flow.2b20ce87b3', 'Force Delete')}
</Button>
<WorktreeForceDeleteButton
toastId={toastId}
onForceDelete={onForceDelete}
onAlwaysForceDelete={onAlwaysForceDelete}
/>
) : null}
{canWaiveArchiveHook ? (
<Button type="button" variant="destructive" size="sm" onClick={deleteAnyway}>
@@ -115,6 +117,7 @@ export function showDeleteWorktreeFailureToast({
canWaiveArchiveHook,
onViewChanges,
onForceDelete,
onAlwaysForceDelete,
onDeleteAnyway,
worktreeId,
worktreeName,
@@ -146,6 +149,7 @@ export function showDeleteWorktreeFailureToast({
showViewChanges={!isLockedWorktreeRemovalError(error) || hasKnownChanges === true}
onViewChanges={onViewChanges}
onForceDelete={onForceDelete}
onAlwaysForceDelete={onAlwaysForceDelete}
onDeleteAnyway={onDeleteAnyway}
toastId={id}
nestedRemovalTarget={nestedTarget}
@@ -60,6 +60,8 @@ export function runWorktreeDeleteWithToast(
lockReason: state?.lockReason ?? null,
hasKnownChanges,
onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId),
onAlwaysForceDelete: () =>
useAppStore.getState().updateSettingsOrThrow({ alwaysForceDeleteWorktrees: true }),
// Why (#19334): re-runs the archive hook and waives the failure this time, so the waiver
// is an informed choice made after reading the refusal -- not something `force` implied.
onDeleteAnyway: () =>
+6
View File
@@ -19475,6 +19475,12 @@
"fileSearch": {
"failed": "Search failed. Try again."
},
"workspaceDeletion": {
"preferenceSaveFailed": "Could not save deletion preference",
"alwaysForceDelete": "Always force delete",
"moreActions": "More force delete options",
"alwaysForceDeleteTooltip": "Applies to all future workspace deletions."
},
"worktree": {
"nestedRemoval": {
"refreshFailed": "Could not refresh the workspace list",
@@ -0,0 +1,117 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { makeWorktree } from './worktrees-slice-test-fixtures'
import {
createTestStore,
mockApi,
resetRemoteRuntimeMocks,
resetWorktreeSliceModuleMemory,
runtimeEnvironmentCall
} from './worktrees-slice-test-harness'
beforeEach(() => {
vi.clearAllMocks()
resetWorktreeSliceModuleMemory()
resetRemoteRuntimeMocks()
mockApi.worktrees.remove.mockResolvedValue({})
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-rm',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-remote' }
})
})
it.each([undefined, false, true])(
'uses the saved force-delete preference for local deletion: %s',
async (enabled) => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1' })
store.setState({
settings: { ...getDefaultSettings('/home/test'), alwaysForceDeleteWorktrees: enabled },
worktreesByRepo: { [wt.repoId]: [wt] }
})
expect(
await store.getState().removeWorktree({ id: wt.id, executionHostId: null }, false)
).toEqual({ ok: true })
expect(mockApi.worktrees.remove).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: wt.id,
force: enabled === true,
allowUnverifiedPtyStop: enabled === true,
allowFailedArchiveHook: false
})
)
}
)
it('applies the preference through the existing remote removal flags', 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'),
activeRuntimeEnvironmentId: 'different-focused-hub',
alwaysForceDeleteWorktrees: true
},
worktreesByRepo: { [wt.repoId]: [wt] }
})
expect(
await store.getState().removeWorktree({ id: wt.id, executionHostId: 'runtime:env-1' }, false)
).toEqual({ ok: true })
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'env-1',
method: 'worktree.rm',
params: expect.objectContaining({
worktree: `id:${wt.id}`,
force: true,
allowUnverifiedPtyStop: true,
runHooks: true
})
})
)
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
})
it('still reports a failed forced removal instead of treating the workspace as deleted', async () => {
mockApi.worktrees.remove.mockRejectedValue(new Error('Workspace is locked'))
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1' })
store.setState({
settings: { ...getDefaultSettings('/home/test'), alwaysForceDeleteWorktrees: true },
worktreesByRepo: { [wt.repoId]: [wt] }
})
expect(
await store.getState().removeWorktree({ id: wt.id, executionHostId: null }, false)
).toEqual({ ok: false, error: 'Workspace is locked' })
expect(store.getState().worktreesByRepo[wt.repoId]).toEqual([wt])
})
it('keeps forgetting a disconnected workspace metadata-only when the preference is enabled', async () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', hostId: 'ssh:offline' })
store.setState({
settings: { ...getDefaultSettings('/home/test'), alwaysForceDeleteWorktrees: true },
worktreesByRepo: { [wt.repoId]: [wt] }
})
expect(
await store.getState().removeWorktree({ id: wt.id, executionHostId: 'ssh:offline' }, false, {
mode: 'forget-local'
})
).toEqual({ ok: true })
expect(mockApi.worktrees.forgetLocal).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: wt.id })
)
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
@@ -42,6 +42,7 @@ import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-
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'
export function createRemoveWorktree(
set: WorktreeSliceSet,
@@ -49,7 +50,7 @@ export function createRemoveWorktree(
): WorktreeSlice['removeWorktree'] {
return async (removalTarget, force, options) => {
const worktreeId = removalTarget.id
const forgetLocalOnly = options?.mode === 'forget-local'
const policy = resolveWorktreeRemovalPreference(get().settings, force, options)
// Why (STA-4343): this is the ONE chokepoint every delete entry point shares,
// so host qualification is enforced here rather than at any single caller — a
// guard bolted onto the sidebar would drift from the cleanup dialog's. The
@@ -60,7 +61,7 @@ export function createRemoveWorktree(
get,
worktreeId,
requiredExecutionHostId,
forgetLocalOnly,
policy.forgetLocalOnly,
options?.ignoreWorkspaceCleanupScanSurvivors === true
)
if (!start.ok) {
@@ -94,7 +95,7 @@ export function createRemoveWorktree(
try {
// Why: forget-local touches no remote, so there's no archive hook to run or trust prompt needed.
// Why `get`: same-repo local deletes start together, and one approval must cover the rest.
const skipArchive = forgetLocalOnly
const skipArchive = policy.forgetLocalOnly
? true
: (await ensureHooksConfirmed(
get,
@@ -110,7 +111,7 @@ export function createRemoveWorktree(
requiredExecutionHostId
)
const beforeRemoval = captureWorktreeStateBeforeRemoval(get(), worktreeId, hostId)
if (!forgetLocalOnly) {
if (!policy.forgetLocalOnly) {
removalGenerationGuard?.assertCurrent()
}
// Why: forget-local clears Orca's records via local IPC regardless of host — the remote is gone or unreachable.
@@ -121,28 +122,28 @@ export function createRemoveWorktree(
? { ...get().settings, activeRuntimeEnvironmentId: null }
: { activeRuntimeEnvironmentId: null }
)
const unprovableRemoteRouting = forgetLocalOnly
const unprovableRemoteRouting = policy.forgetLocalOnly
? null
: refuseUnprovableRemoteHostRouting(get, worktreeId, target.kind)
if (unprovableRemoteRouting) {
throw new Error(unprovableRemoteRouting)
}
let removalResult: RemoveWorktreeResult
let snapshotPruneHandledByLocalMain = forgetLocalOnly || target.kind === 'local'
let snapshotPruneHandledByLocalMain = policy.forgetLocalOnly || target.kind === 'local'
try {
removalResult = await dispatchWorktreeRemoval({
worktreeId,
hostId,
force,
force: policy.force,
skipArchive,
get,
target,
options,
options: policy.options,
assertCurrent: () => removalGenerationGuard?.assertCurrent()
})
} catch (error) {
if (
!forgetLocalOnly &&
!policy.forgetLocalOnly &&
target.kind !== 'local' &&
(isRuntimeRepoNotFoundError(error) || isRuntimeSelectorNotFoundError(error))
) {
@@ -305,8 +306,8 @@ export function createRemoveWorktree(
const error = readIpcErrorDetail(err) ?? (err instanceof Error ? err.message : String(err))
const forceDeleteReason = classifyWorktreeForceDeleteReason(
error,
force,
options?.allowUnverifiedPtyStop === true
policy.force,
policy.options?.allowUnverifiedPtyStop === true
)
const locked = isLockedWorktreeRemovalError(error)
// Why (#19334): the refusal is the only failure a retry can clear by waiving rather than by
@@ -0,0 +1,20 @@
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { RemoveWorktreeOptions } from '../../worktree-removal-options'
export function resolveWorktreeRemovalPreference(
settings: GlobalSettings | null,
force: boolean | undefined,
options: RemoveWorktreeOptions | undefined
): {
forgetLocalOnly: boolean
force: boolean | undefined
options: RemoveWorktreeOptions | undefined
} {
const forgetLocalOnly = options?.mode === 'forget-local'
const alwaysForceDelete = !forgetLocalOnly && settings?.alwaysForceDeleteWorktrees === true
return {
forgetLocalOnly,
force: alwaysForceDelete || force,
options: alwaysForceDelete ? { ...options, allowUnverifiedPtyStop: true } : options
}
}
+1
View File
@@ -200,6 +200,7 @@ export function buildDefaultSettings(args: {
devPluginPaths: [],
claudeAgentTeamsDefaultDisabledMigrated: true,
skipDeleteWorktreeConfirm: false,
alwaysForceDeleteWorktrees: false,
skipCloseTerminalWithRunningProcessConfirm: false,
skipDeleteAutomationConfirm: false,
skipDeleteArtifactConfirm: false,
+2
View File
@@ -361,6 +361,8 @@ export type GlobalSettings = NativeChatGlobalSettings & {
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. */