From ed60f5c6e1e25ccd1662903c43c77b866b465fd9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:31:52 -0700 Subject: [PATCH 01/46] fix(worktrees): delete removed checkouts in git, not in Orca's file pool Local worktree removal renamed the checkout into a sibling trash root and deleted it in the background with a recursive fs.rm in the main process. That queued one request per entry on libuv's shared 4-thread file pool, so for minutes every other async fs call in the main process (the agent-session store behind chat sends, file explorer reads) waited behind the delete. `git worktree remove` now deletes the checkout inline in git's own process again, so the card stays in its Deleting state for the length of the delete while Orca's file pool stays free. No timeout applies to the call, so a large delete is never killed halfway. If git reports success but the path still exists (Git for Windows leaves junctions and their parent directories in place), the leftover is deleted with the existing removeHostTree; WSL checkouts stay with the distro. Nothing creates trash any more: the scheduling queue, rename/restore helpers and the trash_rename span are gone. The startup sweep stays to drain entries older releases left behind, and now removes each emptied trash root so the obligation ends. --- src/main/git/add-sparse-worktree.test.ts | 22 +-- .../remove-worktree-branch-cleanup.test.ts | 22 +-- .../remove-worktree-clean-preflight.test.ts | 22 +-- src/main/git/remove-worktree-test-harness.ts | 16 +- src/main/git/remove-worktree.test.ts | 181 +++++------------- .../git/worktree-add-creation-config.test.ts | 25 +-- .../worktree-add-local-base-refresh.test.ts | 25 +-- ...worktree-add-local-base-suggestion.test.ts | 25 +-- .../git/worktree-add-timeout-override.test.ts | 25 +-- src/main/git/worktree-graph-listing.test.ts | 25 +-- src/main/git/worktree-list-porcelain.test.ts | 22 +-- src/main/git/worktree-move.test.ts | 9 - ...rktree-mutation-route-invalidation.test.ts | 7 - .../git/worktree-porcelain-parsing.test.ts | 26 +-- ...rktree-posix-path-case-sensitivity.test.ts | 39 +--- .../worktree-removal-large-tree.bench.test.ts | 95 +++++++-- ...t.ts => worktree-removal-real-git.test.ts} | 123 ++++++++---- src/main/git/worktree-removal.ts | 130 ++++--------- .../worktree-remove-branch-deletion.test.ts | 31 +-- .../git/worktree-scan-cache-sharing.test.ts | 25 +-- src/main/observability/instrumentation.ts | 1 - .../startup/main-process-ready-runtime.ts | 4 +- src/main/worktree-trash.test.ts | 118 +----------- src/main/worktree-trash.ts | 101 ++-------- .../git-branch-delete-refusal-parity.test.ts | 18 +- src/shared/git-binary-compatibility.test.ts | 15 +- 26 files changed, 346 insertions(+), 806 deletions(-) rename src/main/git/{worktree-deferred-removal-real-git.test.ts => worktree-removal-real-git.test.ts} (57%) diff --git a/src/main/git/add-sparse-worktree.test.ts b/src/main/git/add-sparse-worktree.test.ts index 2541b822438e..987fcb5814c7 100644 --- a/src/main/git/add-sparse-worktree.test.ts +++ b/src/main/git/add-sparse-worktree.test.ts @@ -7,26 +7,14 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock + resolveGitDirMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -59,11 +47,7 @@ const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) beforeEach(() => { - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() }) describe('addSparseWorktree', () => { diff --git a/src/main/git/remove-worktree-branch-cleanup.test.ts b/src/main/git/remove-worktree-branch-cleanup.test.ts index 6c4885bca75d..d6e75c1b33e3 100644 --- a/src/main/git/remove-worktree-branch-cleanup.test.ts +++ b/src/main/git/remove-worktree-branch-cleanup.test.ts @@ -7,26 +7,14 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock + resolveGitDirMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -59,11 +47,7 @@ const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) beforeEach(() => { - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() }) describe('removeWorktree', () => { diff --git a/src/main/git/remove-worktree-clean-preflight.test.ts b/src/main/git/remove-worktree-clean-preflight.test.ts index 190074ed1517..ebaec39cee43 100644 --- a/src/main/git/remove-worktree-clean-preflight.test.ts +++ b/src/main/git/remove-worktree-clean-preflight.test.ts @@ -7,26 +7,14 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock + resolveGitDirMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -50,11 +38,7 @@ import { resetWorktreeRemovalState } from './remove-worktree-test-harness' import { assertWorktreeCleanForRemoval, WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS } from './worktree' beforeEach(() => { - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() }) describe('assertWorktreeCleanForRemoval', () => { diff --git a/src/main/git/remove-worktree-test-harness.ts b/src/main/git/remove-worktree-test-harness.ts index 1f8be9b7fcc9..509c6b580eae 100644 --- a/src/main/git/remove-worktree-test-harness.ts +++ b/src/main/git/remove-worktree-test-harness.ts @@ -80,23 +80,11 @@ export function mockSparseCheckoutEnabledConfig(readFileMock: Mock): void { }) } -export type WorktreeTrashMocks = { - moveWorktreeDirectoryToTrashMock: Mock - restoreWorktreeDirectoryFromTrashMock: Mock - scheduleWorktreeTrashDeletionMock: Mock -} - -/** Per-test reset shared by every worktree suite: caches cleared, trash rename unavailable. */ -export function resetWorktreeRemovalState(trashMocks: WorktreeTrashMocks): void { +/** Per-test reset shared by every worktree suite: caches cleared. */ +export function resetWorktreeRemovalState(): void { clearGitCapabilityStateForTests() _resetWorktreeScanCacheForTests() __resetSparseCheckoutStateCacheForTests() - // Default: the checkout cannot be renamed aside, so removal deletes it in place. - trashMocks.moveWorktreeDirectoryToTrashMock.mockReset() - trashMocks.moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) - trashMocks.restoreWorktreeDirectoryFromTrashMock.mockReset() - trashMocks.restoreWorktreeDirectoryFromTrashMock.mockResolvedValue(true) - trashMocks.scheduleWorktreeTrashDeletionMock.mockReset() } export type WorktreeGitMocks = { diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 006ee3087bb1..46f3ff4ade5e 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -1,4 +1,8 @@ +import { existsSync } from 'node:fs' import type * as FsPromises from 'node:fs/promises' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -7,26 +11,14 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock + resolveGitDirMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -53,7 +45,7 @@ import { resetWorktreeRemovalState } from './remove-worktree-test-harness' -import { removeWorktree, WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' +import { removeWorktree } from './worktree' import { __getSparseCheckoutStateCacheSizeForTests, detectSparseCheckoutCached @@ -63,11 +55,7 @@ const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) beforeEach(() => { - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() }) describe('removeWorktree', () => { @@ -223,8 +211,6 @@ branch refs/heads/main const calls = getGitCalls() expect(calls).toEqual([ 'git worktree list --porcelain -z', - // The cleanliness probe that decides whether the checkout may be renamed aside. - 'git status --porcelain --untracked-files=all', 'git worktree remove /repo-feature', 'git branch -d -- feature/test', 'git worktree prune', @@ -232,43 +218,7 @@ branch refs/heads/main ]) }) - it('renames the checkout aside and deregisters the missing path', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - } - }) - moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-1-abcdef01') - - await removeWorktree('/repo', '/repo-feature') - - const calls = getGitCalls() - expect(moveWorktreeDirectoryToTrashMock).toHaveBeenCalledWith('/repo-feature') - expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-1-abcdef01') - expect(calls).toContain('git worktree remove --force /repo-feature') - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['worktree', 'remove', '--force', '/repo-feature'], - { cwd: '/repo', timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } - ) - expect(calls).not.toContain('git worktree remove /repo-feature') - expect(calls).not.toContain('git worktree prune') - expect(calls).toContain('git branch -d -- feature/test') - }) - - it('prunes the registration when deregistering the moved checkout fails', async () => { + it('lets Git delete the checkout inline with no deadline', async () => { mockGitCommands({ 'git worktree list --porcelain': { stdout: `worktree /repo @@ -285,88 +235,57 @@ branch refs/heads/feature/test HEAD abc123 branch refs/heads/main ` - }, - 'git worktree remove --force /repo-feature': { - error: new Error('fatal: validation failed, cannot remove working directory') } }) - moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-2-abcdef02') await removeWorktree('/repo', '/repo-feature') - expect(getGitCalls()).toContain('git worktree prune') - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { - cwd: '/repo', - timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + // A large checkout takes Git 30 s or more to delete; any timeout here would cut that short. + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'remove', '/repo-feature'], { + cwd: '/repo' }) - expect( - gitExecFileAsyncMock.mock.calls.filter( - ([args, options]) => - args.join(' ') === 'worktree list --porcelain -z' && - options.timeout === WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS - ) - ).toHaveLength(2) - expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-2-abcdef02') - expect(restoreWorktreeDirectoryFromTrashMock).not.toHaveBeenCalled() + expect(getGitCalls()).not.toContain('git worktree prune') + expect(getGitCalls()).toContain('git branch -d -- feature/test') }) - it('restores the moved checkout and removes in place when the registration survives pruning', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree remove --force /repo-feature': { - error: new Error('fatal: validation failed, cannot remove working directory') - } - }) - moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-3-abcdef03') + it('deletes what Git left of the checkout after a successful remove', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'orca-remove-residue-')) + const worktreePath = join(scratch, 'feature') + // Git for Windows leaves junctions and the directories holding them behind yet exits 0. + await mkdir(join(worktreePath, 'node_modules', 'left-behind'), { recursive: true }) + try { + mockGitCommands({}) - await removeWorktree('/repo', '/repo-feature') + await removeWorktree('/repo', worktreePath, false, { + knownRemovedWorktree: { branch: '', head: '', locked: false } + }) - expect(restoreWorktreeDirectoryFromTrashMock).toHaveBeenCalledWith( - '/trash/wt-3-abcdef03', - '/repo-feature' - ) - expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled() - expect(getGitCalls()).toContain('git worktree remove /repo-feature') + expect(getGitCalls()).toEqual([`git worktree remove ${worktreePath}`]) + expect(existsSync(worktreePath)).toBe(false) + } finally { + await rm(scratch, { recursive: true, force: true }) + } }) - it('removes in place when the checkout cannot be renamed aside', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main - -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - } - }) - // Windows open handles and cross-volume renames both surface as an unavailable rename. - moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) + it('leaves a WSL checkout that Git reported removed to the distro', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'orca-remove-residue-wsl-')) + const worktreePath = join(scratch, 'feature') + await mkdir(worktreePath, { recursive: true }) + try { + mockGitCommands({}) - await removeWorktree('/repo', '/repo-feature') + await removeWorktree('/repo', worktreePath, false, { + wslDistro: 'Ubuntu', + knownRemovedWorktree: { branch: '', head: '', locked: false } + }) - expect(getGitCalls()).toContain('git worktree remove /repo-feature') - expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled() + expect(existsSync(worktreePath)).toBe(true) + } finally { + await rm(scratch, { recursive: true, force: true }) + } }) - it('never renames a dirty checkout aside', async () => { + it('leaves a dirty checkout to Git to refuse', async () => { mockGitCommands({ 'git worktree list --porcelain': { stdout: `worktree /repo @@ -387,7 +306,7 @@ branch refs/heads/feature/test await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( 'contains modified or untracked files' ) - expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled() + expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') }) it('deletes WSL-hosted checkouts in place inside the distro', async () => { @@ -412,12 +331,11 @@ branch refs/heads/main await removeWorktree('/repo', '/repo-feature', false, { wslDistro: 'Ubuntu' }) - expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled() expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') expect(getGitCalls()).toContain('git worktree remove /repo-feature') }) - it('does not rename a WSL checkout configured for a native Windows repo', async () => { + it('removes a WSL checkout configured for a native Windows repo with Git alone', async () => { const originalPlatform = process.platform const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\feature' Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -428,7 +346,6 @@ branch refs/heads/main knownRemovedWorktree: { branch: '', head: '', locked: false } }) - expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled() expect(getGitCalls()).toEqual([`git worktree remove ${worktreePath}`]) } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) @@ -574,10 +491,8 @@ branch refs/heads/feature/test 'git worktree remove failed' ) expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') - // Only the pre-rename probe ran: an unrelated failure must not re-prove cleanliness. - expect( - getGitCalls().filter((call) => call === 'git status --porcelain --untracked-files=all') - ).toHaveLength(1) + // An unrelated failure must not re-prove cleanliness. + expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') }) it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => { diff --git a/src/main/git/worktree-add-creation-config.test.ts b/src/main/git/worktree-add-creation-config.test.ts index ff44ca7ac6db..0fbd26299060 100644 --- a/src/main/git/worktree-add-creation-config.test.ts +++ b/src/main/git/worktree-add-creation-config.test.ts @@ -1,17 +1,13 @@ // addWorktree: checkout creation, branch-base/push.autoSetupRemote config writes, ref qualification. import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { addSparseWorktree, addWorktree, WORKTREE_ADD_TIMEOUT_MS } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' diff --git a/src/main/git/worktree-add-local-base-refresh.test.ts b/src/main/git/worktree-add-local-base-refresh.test.ts index 1ba2e6c16b88..c5ad0418d279 100644 --- a/src/main/git/worktree-add-local-base-refresh.test.ts +++ b/src/main/git/worktree-add-local-base-refresh.test.ts @@ -1,17 +1,13 @@ // addWorktree: fast-forwarding the local base ref (reset --hard / update-ref) and its safety bailouts. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { addWorktree, WORKTREE_ADD_TIMEOUT_MS } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' diff --git a/src/main/git/worktree-add-local-base-suggestion.test.ts b/src/main/git/worktree-add-local-base-suggestion.test.ts index 3b449c339f6d..e17581a55f61 100644 --- a/src/main/git/worktree-add-local-base-suggestion.test.ts +++ b/src/main/git/worktree-add-local-base-suggestion.test.ts @@ -1,17 +1,13 @@ // addWorktree: advisory local-base-ref update suggestions when the refresh setting is off. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { addWorktree } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' diff --git a/src/main/git/worktree-add-timeout-override.test.ts b/src/main/git/worktree-add-timeout-override.test.ts index 97666c09d4ae..b553801227be 100644 --- a/src/main/git/worktree-add-timeout-override.test.ts +++ b/src/main/git/worktree-add-timeout-override.test.ts @@ -1,17 +1,13 @@ // resolveWorktreeAddTimeoutMs: ORCA_WORKTREE_ADD_TIMEOUT_MS parsing, clamping, and warnings. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { resolveWorktreeAddTimeoutMs, WORKTREE_ADD_TIMEOUT_MAX_MS, diff --git a/src/main/git/worktree-graph-listing.test.ts b/src/main/git/worktree-graph-listing.test.ts index b626718de696..1e707fd9eccb 100644 --- a/src/main/git/worktree-graph-listing.test.ts +++ b/src/main/git/worktree-graph-listing.test.ts @@ -1,17 +1,13 @@ // listWorktreeGraph: -z porcelain listing plus the older-Git usage-error fallback. import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { listWorktreeGraph, WORKTREE_LIST_TIMEOUT_MS } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' diff --git a/src/main/git/worktree-list-porcelain.test.ts b/src/main/git/worktree-list-porcelain.test.ts index 5ef3630639d4..eed9891ac224 100644 --- a/src/main/git/worktree-list-porcelain.test.ts +++ b/src/main/git/worktree-list-porcelain.test.ts @@ -7,26 +7,14 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock + resolveGitDirMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -58,11 +46,7 @@ const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) beforeEach(() => { - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() }) describe('listWorktrees', () => { diff --git a/src/main/git/worktree-move.test.ts b/src/main/git/worktree-move.test.ts index 9da3ddbb515f..2832639c1f16 100644 --- a/src/main/git/worktree-move.test.ts +++ b/src/main/git/worktree-move.test.ts @@ -5,13 +5,11 @@ const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock, detectSparseCheckoutMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn(), detectSparseCheckoutMock: vi.fn() })) @@ -21,13 +19,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - vi.mock('./worktree-sparse-state', () => ({ detectSparseCheckout: detectSparseCheckoutMock, resolveGitCommonDir: vi.fn() diff --git a/src/main/git/worktree-mutation-route-invalidation.test.ts b/src/main/git/worktree-mutation-route-invalidation.test.ts index 351a24a1c98d..785ed3d13916 100644 --- a/src/main/git/worktree-mutation-route-invalidation.test.ts +++ b/src/main/git/worktree-mutation-route-invalidation.test.ts @@ -25,13 +25,6 @@ vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => Promise) => run() })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: vi.fn().mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - vi.mock('./worktree', async (importOriginal) => ({ ...(await importOriginal()), resolveWorktreeAddBaseContext: vi.fn(async () => ({ effectiveBase: 'origin/main' })), diff --git a/src/main/git/worktree-porcelain-parsing.test.ts b/src/main/git/worktree-porcelain-parsing.test.ts index ae5c1ed569f4..f9009077f6c4 100644 --- a/src/main/git/worktree-porcelain-parsing.test.ts +++ b/src/main/git/worktree-porcelain-parsing.test.ts @@ -1,17 +1,13 @@ // parseWorktreeList: porcelain block parsing, lock/prunable markers, NUL-delimited output. import { describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { parseWorktreeList } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' @@ -190,7 +179,6 @@ detached HEAD aaa111 branch refs/heads/main - worktree /repo-b HEAD bbb222 branch refs/heads/dev diff --git a/src/main/git/worktree-posix-path-case-sensitivity.test.ts b/src/main/git/worktree-posix-path-case-sensitivity.test.ts index 7e6204a955e7..3c96acbadc3c 100644 --- a/src/main/git/worktree-posix-path-case-sensitivity.test.ts +++ b/src/main/git/worktree-posix-path-case-sensitivity.test.ts @@ -12,31 +12,14 @@ import type * as FsPromises from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as WslPathTranslation from './command-runner/wsl-path-translation' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - statMock, - readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - statMock: vi.fn(), - readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, statMock, readFileMock, resolveGitDirMock } = + vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + statMock: vi.fn(), + readFileMock: vi.fn(), + resolveGitDirMock: vi.fn() + })) // Why the real translator: an identity mock removes the Linux -> UNC rewrite that production // always applies, which is the only reason the Linux spelling would ever reach the comparison. @@ -151,11 +134,7 @@ describe('removeWorktree branch selection on a Windows desktop', () => { readFileMock.mockReset() readFileMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) resolveGitDirMock.mockReset() - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() }) afterEach(() => { diff --git a/src/main/git/worktree-removal-large-tree.bench.test.ts b/src/main/git/worktree-removal-large-tree.bench.test.ts index debeb6210ed8..bfdc9df7e03d 100644 --- a/src/main/git/worktree-removal-large-tree.bench.test.ts +++ b/src/main/git/worktree-removal-large-tree.bench.test.ts @@ -1,21 +1,24 @@ -// Manual benchmark for the `worktree.remove.git_remove` stage on a large checkout. -// Opt in (it builds ~100k files): ORCA_WORKTREE_REMOVAL_BENCH=1 pnpm exec vitest run \ -// --config config/vitest.config.ts src/main/git/worktree-removal-large-tree.bench.test.ts +// Manual benchmark: how long removing a large checkout takes, and how slow a store-style write gets +// meanwhile. Opt in (it builds ~100k files): ORCA_WORKTREE_REMOVAL_BENCH=1 ./node_modules/.bin/vitest \ +// run --config config/vitest.config.ts src/main/git/worktree-removal-large-tree.bench.test.ts import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { appendFileSync, statSync } from 'node:fs' +import { link, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { performance } from 'node:perf_hooks' import { promisify } from 'node:util' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { removeWorktree } from './worktree' -import { getWorktreeTrashRoot, whenWorktreeTrashDeletionsSettled } from '../worktree-trash' const execFileAsync = promisify(execFile) const describeBench = process.env.ORCA_WORKTREE_REMOVAL_BENCH ? describe : describe.skip const FIXTURE_DIRECTORIES = 200 const FIXTURE_FILES_PER_DIRECTORY = 500 +const STORE_FILE_BYTES = 2 * 1024 * 1024 +const STORE_TRANSACTION_INTERVAL_MS = 20 describeBench('worktree removal on a large checkout', () => { let scratchDir = '' @@ -55,31 +58,81 @@ describeBench('worktree removal on a large checkout', () => { }, 900_000) afterAll(async () => { - await whenWorktreeTrashDeletionsSettled() if (scratchDir) { await rm(scratchDir, { recursive: true, force: true }) } }) - it('returns from removeWorktree without waiting for the recursive delete', async () => { - const startedAt = Date.now() - await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) - const userVisibleMs = Date.now() - startedAt + it('reports removal time and store-write latency during the delete', async () => { + // Outside hard links drop to one link only once the checkout's copies are gone, wherever the + // delete runs, so the window covers the whole delete rather than just the call. + const sentinelRoot = join(scratchDir, 'sentinels') + await mkdir(sentinelRoot) + const sentinels: string[] = [] + for (let dirIndex = 0; dirIndex < FIXTURE_DIRECTORIES; dirIndex += 10) { + const sentinel = join(sentinelRoot, `pkg-${dirIndex}`) + await link(join(worktreePath, 'node_modules', `pkg-${dirIndex}`, 'file-0.js'), sentinel) + sentinels.push(sentinel) + } + const checkoutDeleted = (): boolean => sentinels.every((path) => statSync(path).nlink === 1) + const storePath = join(scratchDir, 'store.json') + await writeFile(storePath, Buffer.alloc(STORE_FILE_BYTES, 1)) - const trashRoot = getWorktreeTrashRoot(worktreePath) - console.log( - `[bench] files=${FIXTURE_DIRECTORIES * FIXTURE_FILES_PER_DIRECTORY} removeWorktree=${userVisibleMs}ms` + // Mirrors a persisted-store write: read, write a temp file, fsync it, rename it into place. + const storeTransaction = async (): Promise => { + const startedAt = performance.now() + const contents = await readFile(storePath) + const tempPath = `${storePath}.tmp` + const handle = await open(tempPath, 'w') + try { + await handle.writeFile(contents) + await handle.sync() + } finally { + await handle.close() + } + await rename(tempPath, storePath) + return performance.now() - startedAt + } + const idleMs: number[] = [] + for (let index = 0; index < 10; index += 1) { + idleMs.push(await storeTransaction()) + } + + let removalReturnedMs = 0 + const startedAt = performance.now() + const removal = removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }).then( + () => { + removalReturnedMs = performance.now() - startedAt + } ) + const duringMs: number[] = [] + while (!removalReturnedMs || !checkoutDeleted()) { + const transactionStartedAt = performance.now() + duringMs.push(await storeTransaction()) + const wait = STORE_TRANSACTION_INTERVAL_MS - (performance.now() - transactionStartedAt) + if (wait > 0) { + await new Promise((resolve) => setTimeout(resolve, wait)) + } + } + const deletedMs = performance.now() - startedAt + await removal + + const percentile = (values: number[], p: number): number => { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length * p) / 100))] + } + const line = + `[bench] files=${FIXTURE_DIRECTORIES * FIXTURE_FILES_PER_DIRECTORY}` + + ` removeWorktree=${removalReturnedMs.toFixed(0)}ms checkoutDeleted=${deletedMs.toFixed(0)}ms` + + ` storeIdle p50=${percentile(idleMs, 50).toFixed(1)}ms` + + ` storeDuring n=${duringMs.length} p50=${percentile(duringMs, 50).toFixed(1)}ms` + + ` p90=${percentile(duringMs, 90).toFixed(1)}ms max=${Math.max(...duringMs).toFixed(1)}ms` + console.log(line) + if (process.env.ORCA_WORKTREE_REMOVAL_BENCH_LOG) { + appendFileSync(process.env.ORCA_WORKTREE_REMOVAL_BENCH_LOG, `${line}\n`) + } expect(existsSync(worktreePath)).toBe(false) const { stdout } = await execFileAsync('git', ['worktree', 'list'], { cwd: repoPath }) expect(stdout).not.toContain(worktreePath) - - const backgroundStartedAt = Date.now() - await whenWorktreeTrashDeletionsSettled() - console.log( - `[bench] background deletion=${Date.now() - backgroundStartedAt}ms (trash root ${trashRoot})` - ) - expect(existsSync(trashRoot)).toBe(true) - expect(await readdir(trashRoot)).toEqual([]) }, 900_000) }) diff --git a/src/main/git/worktree-deferred-removal-real-git.test.ts b/src/main/git/worktree-removal-real-git.test.ts similarity index 57% rename from src/main/git/worktree-deferred-removal-real-git.test.ts rename to src/main/git/worktree-removal-real-git.test.ts index 06b58b4319ed..f477f564fdde 100644 --- a/src/main/git/worktree-deferred-removal-real-git.test.ts +++ b/src/main/git/worktree-removal-real-git.test.ts @@ -1,22 +1,17 @@ -// Real-binary coverage for deferred worktree deletion: the mocked-runner suite cannot prove that Git -// accepts `worktree remove --force` on a path Orca just renamed away. +// Real-binary coverage for worktree removal: the mocked-runner suite cannot prove what Git deletes, +// deregisters and refuses, or that deleting a checkout leaves Node's file pool free. import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { existsSync, statSync } from 'node:fs' +import { link, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { performance } from 'node:perf_hooks' import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { listWorktreesStrict, removeWorktree } from './worktree' import { isPrunableGitFileWorktree } from '../worktree-prunable-git-file' import { removeStaleLocalWorktreeRegistration } from '../local-worktree-removal-recovery' -import { - getWorktreeTrashRoot, - isWorktreeTrashEntryName, - sweepStaleWorktreeTrash, - whenWorktreeTrashDeletionsSettled, - WORKTREE_TRASH_DIR_NAME -} from '../worktree-trash' +import { sweepStaleWorktreeTrash, WORKTREE_TRASH_DIR_NAME } from '../worktree-trash' const execFileAsync = promisify(execFile) @@ -33,15 +28,15 @@ async function git(args: string[], cwd: string): Promise { beforeEach(async () => { // realpath: macOS hands out /var/... temp paths while Git reports /private/var/..., and Orca // matches the worktree it is removing against Git's own list. - scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-deferred-worktree-removal-'))) + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-removal-'))) repoPath = join(scratchDir, 'repo') workspaceRoot = join(scratchDir, 'workspaces') worktreePath = join(workspaceRoot, 'repo', 'feature') await mkdir(repoPath, { recursive: true }) await mkdir(join(workspaceRoot, 'repo'), { recursive: true }) await git(['init', '-q'], repoPath) - await git(['config', 'user.email', 'deferred@example.invalid'], repoPath) - await git(['config', 'user.name', 'Deferred Removal'], repoPath) + await git(['config', 'user.email', 'removal@example.invalid'], repoPath) + await git(['config', 'user.name', 'Worktree Removal'], repoPath) await writeFile(join(repoPath, 'seed.txt'), 'seed\n') // Committed before the worktree exists so its branch stays merged and branch cleanup can run. await mkdir(join(repoPath, 'node_modules', 'pkg'), { recursive: true }) @@ -52,25 +47,16 @@ beforeEach(async () => { }) afterEach(async () => { - await whenWorktreeTrashDeletionsSettled() await rm(scratchDir, { recursive: true, force: true }) }) -describe('deferred worktree removal against the real Git binary', () => { - it('clears the registration and deletes the renamed checkout in the background', async () => { - const trashRoot = getWorktreeTrashRoot(worktreePath) - +describe('worktree removal against the real Git binary', () => { + it('deletes the checkout and its registration before returning', async () => { await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) - // The user-visible removal is complete: nothing on disk, nothing registered. expect(existsSync(worktreePath)).toBe(false) expect(await git(['worktree', 'list'], repoPath)).not.toContain(worktreePath) - // Only the rename path creates this root, so its presence proves the deletion was deferred. - expect(existsSync(trashRoot)).toBe(true) - expect((await readdir(trashRoot)).every(isWorktreeTrashEntryName)).toBe(true) - - await whenWorktreeTrashDeletionsSettled() - expect(await readdir(trashRoot)).toEqual([]) + expect(existsSync(join(workspaceRoot, 'repo', WORKTREE_TRASH_DIR_NAME))).toBe(false) }) it('leaves sibling worktrees registered', async () => { @@ -89,16 +75,15 @@ describe('deferred worktree removal against the real Git binary', () => { expect(await git(['branch', '--list', 'feature'], repoPath)).toBe('') }) - it('refuses to move a dirty checkout aside', async () => { + it('refuses to delete a dirty checkout', async () => { await writeFile(join(worktreePath, 'seed.txt'), 'edited\n') await expect(removeWorktree(repoPath, worktreePath, false)).rejects.toThrow() expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) expect(await git(['worktree', 'list'], repoPath)).toContain(worktreePath) - expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) }) - it('does not rename a malformed registration that points at the checkout git file', async () => { + it('does not delete a checkout through a malformed registration that names its git file', async () => { const markerPath = join(worktreePath, '.git') const marker = await readFile(markerPath, 'utf8') const adminPath = marker.trim().replace(/^gitdir: /, '') @@ -108,12 +93,10 @@ describe('deferred worktree removal against the real Git binary', () => { await expect( removeWorktree(repoPath, markerPath, true, { deleteBranch: false }) ).rejects.toThrow() - await whenWorktreeTrashDeletionsSettled() expect(await readFile(markerPath, 'utf8')).toBe(marker) expect(await readFile(join(worktreePath, 'untracked.txt'), 'utf8')).toBe('keep this work\n') expect(await git(['branch', '--list', 'feature'], repoPath)).toContain('feature') - expect(existsSync(getWorktreeTrashRoot(markerPath))).toBe(false) }) it('prunes a proven malformed registration while retaining checkout files and its branch', async () => { @@ -147,7 +130,7 @@ describe('deferred worktree removal against the real Git binary', () => { expect(existsSync(adminPath)).toBe(false) }) - it('sweeps trash a previous run left behind', async () => { + it('sweeps trash an older release left behind', async () => { const stalePath = join( workspaceRoot, 'repo', @@ -162,3 +145,79 @@ describe('deferred worktree removal against the real Git binary', () => { expect(existsSync(worktreePath)).toBe(true) }) }) + +const POOL_FIXTURE_FILES = 3_000 +const POOL_SENTINEL_EVERY = 100 + +function queuedFsRequests(): number { + return process + .getActiveResourcesInfo() + .filter((resource) => resource === 'FSReqPromise' || resource === 'FSReqCallback').length +} + +describe('worktree removal and the Node file pool', () => { + it('keeps async file I/O responsive while the checkout is deleted', async () => { + // One flat directory: a recursive delete through the pool would queue every entry at once. + const bulkPath = join(worktreePath, 'bulk') + await mkdir(bulkPath) + for (let start = 0; start < POOL_FIXTURE_FILES; start += 500) { + await Promise.all( + Array.from({ length: 500 }, (_unused, offset) => + writeFile( + join(bulkPath, `file-${start + offset}.js`), + `module.exports = ${start + offset}\n` + ) + ) + ) + } + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'bulk'], worktreePath) + // Outside hard links drop to one link only once the checkout's copies are gone, wherever the + // delete runs, so the window below covers the whole delete rather than just the call. + const sentinelRoot = join(scratchDir, 'sentinels') + await mkdir(sentinelRoot) + const sentinels: string[] = [] + for (let index = 0; index < POOL_FIXTURE_FILES; index += POOL_SENTINEL_EVERY) { + const sentinel = join(sentinelRoot, `file-${index}`) + await link(join(bulkPath, `file-${index}.js`), sentinel) + sentinels.push(sentinel) + } + const checkoutDeleted = (): boolean => sentinels.every((path) => statSync(path).nlink === 1) + const probePath = join(repoPath, 'seed.txt') + + let removalSettled = false + const removal = removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }).finally( + () => { + removalSettled = true + } + ) + const startedAt = performance.now() + let maxQueued = 0 + let maxStatMs = 0 + let statCount = 0 + const sampler = setInterval(() => { + maxQueued = Math.max(maxQueued, queuedFsRequests()) + }, 1) + try { + while (!removalSettled || !checkoutDeleted()) { + expect(performance.now() - startedAt).toBeLessThan(60_000) + maxQueued = Math.max(maxQueued, queuedFsRequests()) + const statStartedAt = performance.now() + await stat(probePath) + maxStatMs = Math.max(maxStatMs, performance.now() - statStartedAt) + statCount += 1 + } + } finally { + clearInterval(sampler) + } + const deletionMs = performance.now() - startedAt + await removal + + console.log( + `[pool] files=${POOL_FIXTURE_FILES} delete=${deletionMs.toFixed(0)}ms stats=${statCount} maxStat=${maxStatMs.toFixed(1)}ms maxQueuedFsRequests=${maxQueued}` + ) + expect(existsSync(worktreePath)).toBe(false) + expect(maxQueued).toBeLessThan(32) + expect(maxStatMs).toBeLessThan(deletionMs / 4) + }, 120_000) +}) diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index afe1bf2a9c10..038a346311eb 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -1,25 +1,16 @@ +import { lstat } from 'node:fs/promises' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal' -import { withWorktreeRemoveStageSpan } from '../observability/instrumentation' +import { removeHostTree } from '../host-tree-removal' import { withSpan } from '../observability/tracer' -import { - moveWorktreeDirectoryToTrash, - restoreWorktreeDirectoryFromTrash, - scheduleWorktreeTrashDeletion -} from '../worktree-trash' import { parseWslPath } from '../wsl' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { deleteBranchAfterWorktreeRemoval } from './worktree-branch-removal' -import { listWorktreesStrict } from './worktree-listing' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' import type { RemoveWorktreeOptions } from './worktree-operation-options' -import { - WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS, - gitExecOptions, - normalizeLocalBranchRef -} from './worktree-operation-options' +import { getErrorCode, gitExecOptions, normalizeLocalBranchRef } from './worktree-operation-options' import { areWorktreePathsEqual } from './worktree-path-comparison' import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' @@ -69,28 +60,27 @@ async function performRemoveWorktree( // Why: callers outside the IPC/runtime preflight must not bypass Git's lock contract or rely on localized stderr after side effects. assertWorktreeUnlockedForRemoval(removedWorktree) - if ( - !(await tryRemoveWorktreeWithDeferredDirectoryDeletion(repoPath, worktreePath, force, options)) - ) { - const args = ['worktree', 'remove'] - if (force) { - args.push('--force') - } - args.push(worktreePath) - try { - await gitExecFileAsync(args, gitExecOptions(repoPath, options)) - } catch (error) { - if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { - throw error - } - // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force. - await assertWorktreeCleanForRemoval(worktreePath, false, options) - await gitExecFileAsync( - ['worktree', 'remove', '--force', worktreePath], - gitExecOptions(repoPath, options) - ) + // Why no timeout: this is a write, so none applies by default, and Git deletes the whole checkout + // here (prod p90 29 s); a deadline would kill a legitimate large delete halfway through. + const args = ['worktree', 'remove'] + if (force) { + args.push('--force') + } + args.push(worktreePath) + try { + await gitExecFileAsync(args, gitExecOptions(repoPath, options)) + } catch (error) { + if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { + throw error } + // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force. + await assertWorktreeCleanForRemoval(worktreePath, false, options) + await gitExecFileAsync( + ['worktree', 'remove', '--force', worktreePath], + gitExecOptions(repoPath, options) + ) } + await removeCheckoutLeftByGit(worktreePath, options) if (!branchName) { return {} @@ -106,78 +96,24 @@ async function performRemoveWorktree( ) } -/** - * Rename the checkout into a sibling trash directory and clear Git's registration for the - * now-missing path, so the multi-GB recursive delete runs after this removal has returned. - * Returns false when the caller must let `git worktree remove` delete the directory inline. - */ -async function tryRemoveWorktreeWithDeferredDirectoryDeletion( - repoPath: string, +// Why: Git for Windows does not descend into junctions and exits 0 with them and their parent +// directories still on disk; finish the delete Git already accepted instead of leaving it behind. +async function removeCheckoutLeftByGit( worktreePath: string, - force: boolean, options: RemoveWorktreeOptions -): Promise { - // Why: WSL-owned checkouts are deleted inside the distro, so Node on Windows must not rename them. +): Promise { + // Why: WSL-owned checkouts are deleted inside the distro, so Node on Windows must not touch them. if (options.wslDistro || parseWslPath(worktreePath)) { - return false - } - if (!force) { - try { - // Why: `git worktree remove` re-checks cleanliness as it removes; prove the same thing here or leave removal to Git. - await assertWorktreeCleanForRemoval(worktreePath, false, options) - } catch { - return false - } - } - - const trashPath = await withWorktreeRemoveStageSpan('trash_rename', 'local', () => - moveWorktreeDirectoryToTrash(worktreePath) - ) - if (!trashPath) { - return false + return } try { - await clearGitRegistrationForMissingWorktree(repoPath, worktreePath, options) + await lstat(worktreePath) } catch (error) { - // Why: put the checkout back so the in-place removal below still sees the worktree Git registered. - if (await restoreWorktreeDirectoryFromTrash(trashPath, worktreePath)) { - return false + if (getErrorCode(error) === 'ENOENT') { + return } throw error } - scheduleWorktreeTrashDeletion(trashPath) - return true -} - -async function clearGitRegistrationForMissingWorktree( - repoPath: string, - worktreePath: string, - options: RemoveWorktreeOptions -): Promise { - const registrationOptions = { - ...options, - timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS - } - try { - // Removing an already-missing directory is accepted back to the Git 2.25 baseline and touches only this entry. - await gitExecFileAsync( - ['worktree', 'remove', '--force', worktreePath], - gitExecOptions(repoPath, registrationOptions) - ) - return - } catch (error) { - console.warn( - `[git] Failed to deregister the moved worktree "${worktreePath}"; pruning instead`, - error - ) - } - - await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, registrationOptions)) - // Strict (not the shared scan): an unreadable repo must not read as proof that the row is gone. - const stillRegistered = (await listWorktreesStrict(repoPath, registrationOptions)).some( - (worktree) => areWorktreePathsEqual(worktree.path, worktreePath) - ) - if (stillRegistered) { - throw new Error(`Git still reports a registration for "${worktreePath}" after pruning it.`) - } + console.warn(`[git] worktree remove left files at "${worktreePath}"; deleting them`) + await removeHostTree(worktreePath) } diff --git a/src/main/git/worktree-remove-branch-deletion.test.ts b/src/main/git/worktree-remove-branch-deletion.test.ts index e00ee48c0e0f..b8535ff2ebeb 100644 --- a/src/main/git/worktree-remove-branch-deletion.test.ts +++ b/src/main/git/worktree-remove-branch-deletion.test.ts @@ -1,17 +1,13 @@ // removeWorktree: branch deletion safety after the checkout is removed. import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { removeWorktree } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' @@ -43,7 +32,6 @@ describe('removeWorktree', () => { it('uses safe `branch -d` and preserves a branch with unmerged commits', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove // Git refuses to delete an unmerged branch with `-d`. gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not fully merged')) // branch -d @@ -60,7 +48,6 @@ describe('removeWorktree', () => { it('deletes the branch when `branch -d` succeeds (fully merged)', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds @@ -75,7 +62,6 @@ describe('removeWorktree', () => { }) it('reuses known removed worktree metadata instead of relisting before removal', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds @@ -87,7 +73,6 @@ describe('removeWorktree', () => { }) expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([ - ['status', '--porcelain', '--untracked-files=all'], ['worktree', 'remove', '/repo-feature'], ['branch', '-d', '--', 'feature/test'] ]) @@ -95,7 +80,6 @@ describe('removeWorktree', () => { it('prunes and retries branch deletion only when Git reports a checked-out branch', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove gitExecFileAsyncMock.mockRejectedValueOnce( new Error("error: cannot delete branch 'feature/test' used by worktree at '/repo-stale'") @@ -107,7 +91,6 @@ describe('removeWorktree', () => { expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([ ['worktree', 'list', '--porcelain', '-z'], - ['status', '--porcelain', '--untracked-files=all'], ['worktree', 'remove', '/repo-feature'], ['branch', '-d', '--', 'feature/test'], ['worktree', 'prune'], diff --git a/src/main/git/worktree-scan-cache-sharing.test.ts b/src/main/git/worktree-scan-cache-sharing.test.ts index c42bec7e7301..eabe471a1c64 100644 --- a/src/main/git/worktree-scan-cache-sharing.test.ts +++ b/src/main/git/worktree-scan-cache-sharing.test.ts @@ -2,17 +2,13 @@ import { worktreeCreateGit } from './worktree-create-git-executor' // Worktree scan sharing: in-flight coalescing and mutation-generation retirement. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -20,13 +16,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { _getWorktreeScanCacheSizesForTests, _resetWorktreeScanCacheForTests, diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index 8569ab6ef048..bba248cd663c 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -297,7 +297,6 @@ export type WorktreeRemoveStage = | 'git_remove' | 'metadata_purge' | 'pty_sweep' - | 'trash_rename' | 'watcher_gate' /** Wrap one stage of a worktree removal. Children share the parent's `kind` so `kind`-filtered diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 26b920652df1..c151817b4100 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -77,8 +77,8 @@ export async function initializeReadyRuntimeServices(): Promise { // Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace. state.emulatorBridge = new EmulatorBridge() runtime.setEmulatorBridge(state.emulatorBridge) - // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or - // crash mid-delete can leave the moved directory on disk. Why deferred: the sweep's recursive + // Why: older releases renamed removed checkouts into a trash root and deleted them in the background, + // so a quit mid-delete left directories on disk; drain them. Why deferred: the sweep's recursive // readdir/rm runs on the same libuv threadpool the window's first paint and worktree-catalog // hydration are reading disk on, and nothing on the startup path consumes its result. runAfterFirstWindowShown(() => { diff --git a/src/main/worktree-trash.test.ts b/src/main/worktree-trash.test.ts index 2819c789bf5f..4d5c163e5bf7 100644 --- a/src/main/worktree-trash.test.ts +++ b/src/main/worktree-trash.test.ts @@ -1,18 +1,12 @@ import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { Repo } from '../shared/repo-types' import { collectWorktreeTrashSweepRoots, - getWorktreeTrashRoot, - isWorktreeTrashEntryName, - moveWorktreeDirectoryToTrash, - restoreWorktreeDirectoryFromTrash, - scheduleWorktreeTrashDeletion, sweepStaleWorktreeTrash, - whenWorktreeTrashDeletionsSettled, WORKTREE_TRASH_DIR_NAME } from './worktree-trash' @@ -23,7 +17,6 @@ beforeEach(async () => { }) afterEach(async () => { - await whenWorktreeTrashDeletionsSettled() await rm(scratchDir, { recursive: true, force: true }) }) @@ -32,112 +25,6 @@ async function createWorktreeDirectory(worktreePath: string): Promise { await writeFile(join(worktreePath, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n') } -describe('moveWorktreeDirectoryToTrash', () => { - it('renames the checkout into a hidden sibling trash root', async () => { - const worktreePath = join(scratchDir, 'repo', 'feature') - await createWorktreeDirectory(worktreePath) - - const trashPath = await moveWorktreeDirectoryToTrash(worktreePath) - - expect(trashPath).toBeDefined() - expect(existsSync(worktreePath)).toBe(false) - expect(trashPath!.startsWith(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))).toBe(true) - expect(existsSync(join(trashPath!, 'node_modules', 'pkg', 'index.js'))).toBe(true) - }) - - it('leaves a file target untouched without creating a trash root', async () => { - const worktreePath = join(scratchDir, '.git') - await writeFile(worktreePath, 'gitdir: /preserved/admin\n') - - expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() - expect(await readFile(worktreePath, 'utf8')).toBe('gitdir: /preserved/admin\n') - expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) - }) - - it('leaves a directory symlink and its target untouched', async () => { - const target = join(scratchDir, 'target') - const worktreePath = join(scratchDir, 'link') - await createWorktreeDirectory(target) - await symlink(target, worktreePath, process.platform === 'win32' ? 'junction' : 'dir') - - expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() - expect(existsSync(join(worktreePath, 'node_modules', 'pkg', 'index.js'))).toBe(true) - expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) - }) - - it('generates sweepable, collision-free entry names', async () => { - const first = await moveWorktreeDirectoryToTrash(await seededWorktree('one')) - const second = await moveWorktreeDirectoryToTrash(await seededWorktree('two')) - - expect(first).not.toEqual(second) - expect(await readdir(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))).toHaveLength(2) - for (const entry of await readdir(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))) { - expect(isWorktreeTrashEntryName(entry)).toBe(true) - } - }) - - it('reports the rename as unavailable and leaves no trash root when the checkout is missing', async () => { - const worktreePath = join(scratchDir, 'repo', 'gone') - - expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() - expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) - }) - - it.skipIf(process.platform === 'win32')('refuses a symlinked trash root', async () => { - const worktreePath = join(scratchDir, 'repo', 'feature') - const externalRoot = join(scratchDir, 'external') - await createWorktreeDirectory(worktreePath) - await mkdir(externalRoot) - await symlink(externalRoot, getWorktreeTrashRoot(worktreePath)) - - expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() - expect(existsSync(worktreePath)).toBe(true) - expect(await readdir(externalRoot)).toEqual([]) - }) - - async function seededWorktree(name: string): Promise { - const worktreePath = join(scratchDir, 'repo', name) - await createWorktreeDirectory(worktreePath) - return worktreePath - } -}) - -describe('restoreWorktreeDirectoryFromTrash', () => { - it('puts the checkout back where Git registered it', async () => { - const worktreePath = join(scratchDir, 'repo', 'feature') - await createWorktreeDirectory(worktreePath) - const trashPath = await moveWorktreeDirectoryToTrash(worktreePath) - - expect(await restoreWorktreeDirectoryFromTrash(trashPath!, worktreePath)).toBe(true) - expect(existsSync(join(worktreePath, 'node_modules', 'pkg', 'index.js'))).toBe(true) - expect(existsSync(trashPath!)).toBe(false) - }) - - it('reports failure instead of throwing when the trashed path is gone', async () => { - const trashRoot = getWorktreeTrashRoot(join(scratchDir, 'repo', 'feature')) - expect( - await restoreWorktreeDirectoryFromTrash( - join(trashRoot, 'wt-1-abcdef01'), - join(scratchDir, 'repo', 'feature') - ) - ).toBe(false) - }) -}) - -describe('scheduleWorktreeTrashDeletion', () => { - it('deletes trashed checkouts in the background', async () => { - const worktreePath = join(scratchDir, 'repo', 'feature') - await createWorktreeDirectory(worktreePath) - const trashPath = await moveWorktreeDirectoryToTrash(worktreePath) - - scheduleWorktreeTrashDeletion(trashPath!) - await whenWorktreeTrashDeletionsSettled() - - expect(existsSync(trashPath!)).toBe(false) - expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(true) - }) -}) - describe('sweepStaleWorktreeTrash', () => { it('removes leftover entries from both workspace layouts and nothing else', async () => { const nestedTrashRoot = join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME) @@ -158,6 +45,9 @@ describe('sweepStaleWorktreeTrash', () => { expect(existsSync(join(nestedTrashRoot, 'unrelated-directory'))).toBe(true) expect(existsSync(join(nestedTrashRoot, 'wt-notes.txt'))).toBe(true) expect(existsSync(join(liveWorktree, 'node_modules', 'pkg', 'index.js'))).toBe(true) + // Nothing refills a trash root, so an emptied one goes; one still holding foreign entries stays. + expect(existsSync(flatTrashRoot)).toBe(false) + expect(existsSync(nestedTrashRoot)).toBe(true) }) it('ignores workspace roots that do not exist', async () => { diff --git a/src/main/worktree-trash.ts b/src/main/worktree-trash.ts index cb4d3d52e03b..088c51bfbc9e 100644 --- a/src/main/worktree-trash.ts +++ b/src/main/worktree-trash.ts @@ -1,10 +1,9 @@ -// Why: `git worktree remove` deletes the whole checkout (usually a multi-GB node_modules) inside the -// remove IPC, so the UI sat on a spinner for 8-35s. Renaming the directory into a sibling trash root -// is a metadata operation, and the recursive delete then runs after the IPC has already returned. +// Why: releases before worktree removal ran inline renamed checkouts into a sibling trash root and +// deleted them in the background; a quit mid-delete left entries behind. Nothing creates trash now, +// so this sweep only drains those legacy entries. -import { randomBytes } from 'node:crypto' -import { lstat, mkdir, readdir, rename, rmdir } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { lstat, readdir, rmdir } from 'node:fs/promises' +import { join } from 'node:path' import { removeHostTree } from './host-tree-removal' import { isFolderRepo } from '../shared/repo-kind' import { computeWorkspaceRoot, getWorktreePathSettings } from './ipc/worktree-logic' @@ -14,93 +13,13 @@ import { parseWslPath } from './wsl' export const WORKTREE_TRASH_DIR_NAME = '.orca-worktree-trash' -// `-`: the nonce keeps concurrent removals of same-named worktrees from colliding. +// `-`: the only names the retired rename ever generated. const TRASH_ENTRY_PATTERN = /^wt-\d+-[0-9a-f]{8}$/ // Why: the sweep must stay cheap on a workspace root holding many repo containers. const TRASH_SWEEP_MAX_CONTAINERS = 200 -/** Trash root for a worktree: a hidden sibling, so the rename always stays on one volume. */ -export function getWorktreeTrashRoot(worktreePath: string): string { - return join(dirname(worktreePath), WORKTREE_TRASH_DIR_NAME) -} - -export function isWorktreeTrashEntryName(entryName: string): boolean { - return TRASH_ENTRY_PATTERN.test(entryName) -} - -/** - * Move a worktree directory aside so the caller can return before it is deleted. - * Returns the trash path, or `undefined` when the rename is unavailable (a different - * volume, or Windows open handles) and the caller must delete in place instead. - */ -export async function moveWorktreeDirectoryToTrash( - worktreePath: string -): Promise { - const trashRoot = getWorktreeTrashRoot(worktreePath) - const trashPath = join(trashRoot, `wt-${Date.now()}-${randomBytes(4).toString('hex')}`) - try { - // A malformed Git registration can name the checkout's .git file. - const worktreeStat = await lstat(worktreePath) - if (!worktreeStat.isDirectory() || worktreeStat.isSymbolicLink()) { - return undefined - } - await mkdir(trashRoot, { recursive: true }) - const trashRootStat = await lstat(trashRoot) - if (!trashRootStat.isDirectory() || trashRootStat.isSymbolicLink()) { - throw new Error(`Refusing non-directory worktree trash root: ${trashRoot}`) - } - await rename(worktreePath, trashPath) - return trashPath - } catch (error) { - console.warn( - `[worktrees] Deferred deletion unavailable for ${worktreePath}; deleting in place`, - error - ) - // Leave no empty trash root behind when the rename never happened; rmdir keeps queued entries. - await rmdir(trashRoot).catch(() => {}) - return undefined - } -} - -/** Undo a trash rename so a failed registration cleanup leaves the worktree exactly as it was. */ -export async function restoreWorktreeDirectoryFromTrash( - trashPath: string, - worktreePath: string -): Promise { - try { - await rename(trashPath, worktreePath) - return true - } catch (error) { - console.warn(`[worktrees] Failed to restore ${worktreePath} from ${trashPath}`, error) - return false - } -} - -// Why serialized: one background delete at a time keeps a burst of removals from saturating disk I/O -// while the user keeps working. -let queuedTrashDeletions: Promise = Promise.resolve() - -export function scheduleWorktreeTrashDeletion(trashPath: string): void { - queuedTrashDeletions = queuedTrashDeletions.then(async () => { - try { - await removeHostTree(trashPath) - } catch (error) { - // Why only a warning: the directory is already invisible to the user, and the startup sweep retries it. - console.warn(`[worktrees] Failed to delete trashed worktree at ${trashPath}`, error) - } - }) -} - -/** Test/shutdown hook: resolves once every queued background deletion has settled. */ -export function whenWorktreeTrashDeletionsSettled(): Promise { - return queuedTrashDeletions -} - -/** - * Delete trash entries left behind by a previous run (a crash or a kill during background deletion). - * Only entries matching the generated name pattern inside a trash root are removed. - */ +/** Delete legacy trash entries. Only entries matching the generated name pattern inside a trash root are removed. */ export async function sweepStaleWorktreeTrash( workspaceRoots: readonly string[] ): Promise<{ removed: number }> { @@ -117,7 +36,7 @@ export async function sweepStaleWorktreeTrash( continue } for (const entry of entries) { - if (!isWorktreeTrashEntryName(entry)) { + if (!TRASH_ENTRY_PATTERN.test(entry)) { continue } try { @@ -130,6 +49,8 @@ export async function sweepStaleWorktreeTrash( ) } } + // Why: nothing refills the root, so drop it once empty; rmdir keeps anything still inside. + await rmdir(trashRoot).catch(() => {}) } if (removed > 0) { console.log(`[worktrees] Swept ${removed} leftover worktree director(ies) from a previous run`) @@ -158,7 +79,7 @@ async function collectExistingTrashRoots(workspaceRoots: readonly string[]): Pro return [...trashRoots] } -/** Workspace roots of local git repos — the only places Orca creates worktree trash. */ +/** Workspace roots of local git repos — the only places Orca ever created worktree trash. */ export function collectWorktreeTrashSweepRoots( repos: readonly Repo[], settings: Pick diff --git a/src/relay/git-branch-delete-refusal-parity.test.ts b/src/relay/git-branch-delete-refusal-parity.test.ts index 71344bca940c..b71cb20d4f43 100644 --- a/src/relay/git-branch-delete-refusal-parity.test.ts +++ b/src/relay/git-branch-delete-refusal-parity.test.ts @@ -13,18 +13,9 @@ import type * as FsPromises from 'node:fs/promises' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, resolveGitDirMock, moveWorktreeDirectoryToTrashMock } = vi.hoisted( - () => ({ - gitExecFileAsyncMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn() - }) -) - -vi.mock('../main/worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: vi.fn(async () => true), - scheduleWorktreeTrashDeletion: vi.fn() +const { gitExecFileAsyncMock, resolveGitDirMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + resolveGitDirMock: vi.fn() })) vi.mock('../main/git/runner', () => ({ @@ -170,9 +161,6 @@ beforeEach(() => { gitExecFileAsyncMock.mockReset() resolveGitDirMock.mockReset() resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) - moveWorktreeDirectoryToTrashMock.mockReset() - // Default: the checkout cannot be renamed aside, so removal runs `worktree remove` in place. - moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) }) describe('relay/desktop branch-delete refusal parity', () => { diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 457dad2d4190..af9dcb9d2af1 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -192,19 +192,6 @@ describeBinaryCompatibility('real Git binary compatibility', () => { } }) - it('deregisters a worktree whose directory was renamed away', async () => { - // Orca renames the checkout into a trash directory and then clears the registration, so every - // supported Git must accept `worktree remove --force` on the now-missing path. - await runGit(['worktree', 'add', '-b', 'compat-deferred', 'deferred-wt']) - await rename(join(repoPath, 'deferred-wt'), join(repoPath, 'deferred-trash')) - - await expect(runGit(['worktree', 'remove', '--force', 'deferred-wt'])).resolves.toBeDefined() - - const remaining = await runGit(['worktree', 'list', '--porcelain']) - expect(remaining.stdout).not.toContain('deferred-wt') - await rm(join(repoPath, 'deferred-trash'), { recursive: true, force: true }) - }) - it('removes locked prepared worktrees without a separate unlock', async () => { await runGit(['worktree', 'add', '--detach', '--no-checkout', 'compat-discard', 'HEAD']) await runGit(['-C', 'compat-discard', 'reset', '--hard', 'HEAD']) From 7e5acff11500152dd5081b4412c090323a41de03 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:38:19 -0700 Subject: [PATCH 02/46] fix(worktrees): let Git delete Windows checkouts with long paths enabled Removal now always runs Git's own recursive delete, and worktree creation checks out with core.longpaths on Windows, so a deep checkout Orca created could fail to delete with "Filename too long" (#6433). The Windows recovery then finishes the delete but keeps the branch. Pass the same command-scoped core.longpaths option to `git worktree remove` so Git can delete what it created. Also point the CI shard timing entry at the renamed real-git removal suite. --- config/scripts/ci-shard-timings.json | 2 +- src/main/git/remove-worktree.test.ts | 2 +- src/main/git/worktree-removal.ts | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 664e82686623..53aff9258c4d 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1792,7 +1792,6 @@ "src/main/git/worktree-create-preparation-real-wsl.test.ts": 271, "src/main/git/worktree-created-description-real-git.test.ts": 1340, "src/main/git/worktree-created-disk-witness.test.ts": 149, - "src/main/git/worktree-deferred-removal-real-git.test.ts": 587, "src/main/git/worktree-diff-stamp-guest-gitdir.test.ts": 40, "src/main/git/worktree-diff-stamp-host-paths.test.ts": 55, "src/main/git/worktree-git-capabilities.test.ts": 248, @@ -1809,6 +1808,7 @@ "src/main/git/worktree-preparation-base-oid.test.ts": 99, "src/main/git/worktree-preparation-cancel-latency.bench.test.ts": 248, "src/main/git/worktree-removal-large-tree.bench.test.ts": 291, + "src/main/git/worktree-removal-real-git.test.ts": 5000, "src/main/git/worktree-remove-branch-deletion.test.ts": 194, "src/main/git/worktree-scan-cache-annotation-reuse.test.ts": 241, "src/main/git/worktree-scan-cache-sharing.test.ts": 284, diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 46f3ff4ade5e..2cf3732b32ba 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -346,7 +346,7 @@ branch refs/heads/main knownRemovedWorktree: { branch: '', head: '', locked: false } }) - expect(getGitCalls()).toEqual([`git worktree remove ${worktreePath}`]) + expect(getGitCalls()).toEqual([`git -c core.longpaths=true worktree remove ${worktreePath}`]) } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 038a346311eb..1248c4973d21 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -1,6 +1,7 @@ import { lstat } from 'node:fs/promises' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal' import { removeHostTree } from '../host-tree-removal' import { withSpan } from '../observability/tracer' @@ -62,7 +63,10 @@ async function performRemoveWorktree( // Why no timeout: this is a write, so none applies by default, and Git deletes the whole checkout // here (prod p90 29 s); a deadline would kill a legitimate large delete halfway through. - const args = ['worktree', 'remove'] + // Why long paths: creation checks out with them on Windows, so deleting without them fails with + // "Filename too long" (#6433) and leaves the branch behind via the Windows recovery. + const longPathArgs = windowsLongPathGitArgs(repoPath) + const args = [...longPathArgs, 'worktree', 'remove'] if (force) { args.push('--force') } @@ -76,7 +80,7 @@ async function performRemoveWorktree( // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force. await assertWorktreeCleanForRemoval(worktreePath, false, options) await gitExecFileAsync( - ['worktree', 'remove', '--force', worktreePath], + [...longPathArgs, 'worktree', 'remove', '--force', worktreePath], gitExecOptions(repoPath, options) ) } From 97b377847a75ec403c7414797875296dd06a18f9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:44:14 -0700 Subject: [PATCH 03/46] fix(worktrees): keep an inherited GIT_ASK_YESNO out of the worktree delete Git for Windows asks $GIT_ASK_YESNO whether to retry when a file stays locked during a recursive delete. Orca's git env inherits the user's environment, so an inherited value would run an arbitrary prompt program in the middle of a removal. Drop it for the removal call only. --- src/main/git/remove-worktree.test.ts | 17 +++++++++++++++++ src/main/git/worktree-removal.ts | 18 ++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 2cf3732b32ba..3b2ba950195a 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -248,6 +248,23 @@ branch refs/heads/main expect(getGitCalls()).toContain('git branch -d -- feature/test') }) + it('never lets Git run an inherited yes/no prompt program during the delete', async () => { + vi.stubEnv('GIT_ASK_YESNO', '/usr/local/bin/prompt') + try { + mockGitCommands({}) + + await removeWorktree('/repo', '/repo-feature', false, { + knownRemovedWorktree: { branch: '', head: '', locked: false } + }) + + const [, removeOptions] = gitExecFileAsyncMock.mock.calls[0] + expect(removeOptions.env).toBeDefined() + expect(removeOptions.env).not.toHaveProperty('GIT_ASK_YESNO') + } finally { + vi.unstubAllEnvs() + } + }) + it('deletes what Git left of the checkout after a successful remove', async () => { const scratch = await mkdtemp(join(tmpdir(), 'orca-remove-residue-')) const worktreePath = join(scratch, 'feature') diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 1248c4973d21..00ccc34406fd 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -66,13 +66,14 @@ async function performRemoveWorktree( // Why long paths: creation checks out with them on Windows, so deleting without them fails with // "Filename too long" (#6433) and leaves the branch behind via the Windows recovery. const longPathArgs = windowsLongPathGitArgs(repoPath) + const execOptions = { ...gitExecOptions(repoPath, options), ...removalGitEnv() } const args = [...longPathArgs, 'worktree', 'remove'] if (force) { args.push('--force') } args.push(worktreePath) try { - await gitExecFileAsync(args, gitExecOptions(repoPath, options)) + await gitExecFileAsync(args, execOptions) } catch (error) { if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { throw error @@ -81,7 +82,7 @@ async function performRemoveWorktree( await assertWorktreeCleanForRemoval(worktreePath, false, options) await gitExecFileAsync( [...longPathArgs, 'worktree', 'remove', '--force', worktreePath], - gitExecOptions(repoPath, options) + execOptions ) } await removeCheckoutLeftByGit(worktreePath, options) @@ -100,6 +101,19 @@ async function performRemoveWorktree( ) } +// Why: Git for Windows runs $GIT_ASK_YESNO when a file stays locked mid-delete; no prompt program may run here. +function removalGitEnv(): { env?: NodeJS.ProcessEnv } { + const inherited = Object.keys(process.env).filter((key) => key.toUpperCase() === 'GIT_ASK_YESNO') + if (inherited.length === 0) { + return {} + } + const env = { ...process.env } + for (const key of inherited) { + delete env[key] + } + return { env } +} + // Why: Git for Windows does not descend into junctions and exits 0 with them and their parent // directories still on disk; finish the delete Git already accepted instead of leaving it behind. async function removeCheckoutLeftByGit( From 09d6707317150d91092a4ee892d0309c55a0b82a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:04:53 -0700 Subject: [PATCH 04/46] perf(worktrees): run worktree deletes under their own limit, outside git admission `git worktree remove` now deletes the whole checkout in Git's own process, which takes 20-35 s on a large tree. It took a general git admission slot at status tier for that whole time, and that cap is as small as two slots on a machine with six or fewer cores, so two deletes blocked every status read. Deletes now skip general admission and queue under their own limit of two per host instead: two concurrent deletes already saturate one disk, and more only slow each other down. Leftover cleanup runs inside the same slot. --- .../git/command-runner/git-admission-state.ts | 3 +- .../git-exec-admission-exempt.test.ts | 61 +++++++++++++++++++ src/main/git/command-runner/git-exec-file.ts | 16 ++--- .../git/command-runner/git-exec-options.ts | 2 + src/main/git/remove-worktree.test.ts | 4 +- src/main/git/worktree-delete-limit.test.ts | 52 ++++++++++++++++ src/main/git/worktree-delete-limit.ts | 34 +++++++++++ src/main/git/worktree-removal.ts | 35 ++++++----- 8 files changed, 184 insertions(+), 23 deletions(-) create mode 100644 src/main/git/command-runner/git-exec-admission-exempt.test.ts create mode 100644 src/main/git/worktree-delete-limit.test.ts create mode 100644 src/main/git/worktree-delete-limit.ts diff --git a/src/main/git/command-runner/git-admission-state.ts b/src/main/git/command-runner/git-admission-state.ts index 60b198660cbd..a9a9ce78ddde 100644 --- a/src/main/git/command-runner/git-admission-state.ts +++ b/src/main/git/command-runner/git-admission-state.ts @@ -8,7 +8,8 @@ export const NETWORK_HEADROOM = 1 export const ROUTE_CAP = 2 export const ROUTE_HEADROOM = 1 export const GIT_ADMISSION_AGING_MS = 15_000 -// General 4+2 and network 3+1 are disjoint, so at most ten git children run globally. +// General 4+2 and network 3+1 are disjoint, so at most ten admitted git children run globally; +// worktree deletes bypass admission under their own limit (WORKTREE_DELETE_CONCURRENCY). export const MAX_GIT_CHILDREN = 10 export type AdmissionClass = 'general' | 'network' diff --git a/src/main/git/command-runner/git-exec-admission-exempt.test.ts b/src/main/git/command-runner/git-exec-admission-exempt.test.ts new file mode 100644 index 000000000000..f838bdfeea7d --- /dev/null +++ b/src/main/git/command-runner/git-exec-admission-exempt.test.ts @@ -0,0 +1,61 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock +})) + +import { gitExecFileAsync } from './git-exec-file' +import { + GitAdmissionScheduler, + _gitAdmissionSnapshotForTests, + _resetGitAdmissionForTests +} from './git-subprocess-admission' + +function mockChild(pid: number): ChildProcess { + const child = new EventEmitter() as EventEmitter & Record + child.pid = pid + child.kill = vi.fn(() => true) + child.stdin = Object.assign(new EventEmitter(), { end: vi.fn() }) + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exec wrapper only reads pid, kill, stdio and events. + return child as unknown as ChildProcess +} + +describe('worktree deletes and general git admission', () => { + beforeEach(() => { + vi.useFakeTimers() + execFileMock.mockReset() + // The smallest general cap a host can have: deletes holding it would stall every status read. + _resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 2, generalHeadroom: 0 })) + }) + + afterEach(() => { + vi.useRealTimers() + _resetGitAdmissionForTests() + }) + + it('runs a status read while deletes that fill the general cap are still running', async () => { + let pid = 100 + execFileMock.mockImplementation(() => mockChild(pid++)) + + for (const path of ['/wt-a', '/wt-b', '/wt-c']) { + void gitExecFileAsync(['worktree', 'remove', path], { cwd: '/repo', admissionExempt: true }) + } + await vi.advanceTimersByTimeAsync(0) + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed ?? 0).toBe(0) + + void gitExecFileAsync(['status', '--porcelain'], { cwd: '/repo' }) + await vi.advanceTimersByTimeAsync(0) + + expect(execFileMock).toHaveBeenCalledTimes(4) + expect(execFileMock.mock.calls[3]?.[1]).toContain('status') + expect(_gitAdmissionSnapshotForTests().queued).toBe(0) + }) +}) diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index acfeb9f8a3e5..13027d395b25 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -65,13 +65,15 @@ async function gitExecFileAsyncUnlocked( const policy = effectiveOptions.useConfiguredSshCommandForNetwork ? await buildNetworkSshPolicyEnv(effectiveOptions) : { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const } - const grant = await acquireGitAdmission({ - args, - cwd: options.cwd, - wslDistro: options.wslDistro, - tier: options.admissionTier, - signal: options.signal - }) + const grant = options.admissionExempt + ? { queueWaitMs: 0, release: () => {} } + : await acquireGitAdmission({ + args, + cwd: options.cwd, + wslDistro: options.wslDistro, + tier: options.admissionTier, + signal: options.signal + }) span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) const terminationState: { current: Promise | null } = { current: null } diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts index 75ced0d30302..93ad47b6520d 100644 --- a/src/main/git/command-runner/git-exec-options.ts +++ b/src/main/git/command-runner/git-exec-options.ts @@ -23,4 +23,6 @@ export type GitExecOptions = { captureWslLoginShellOutput?: boolean /** Scheduler priority for this child; status is the safe default. */ admissionTier?: GitAdmissionTier + /** Skips general admission; only for a caller that bounds its own concurrency (worktree deletes). */ + admissionExempt?: true } diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 3b2ba950195a..abe94a34711c 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -241,8 +241,10 @@ branch refs/heads/main await removeWorktree('/repo', '/repo-feature') // A large checkout takes Git 30 s or more to delete; any timeout here would cut that short. + // Exempt from general admission: deletes queue under their own limit instead. expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'remove', '/repo-feature'], { - cwd: '/repo' + cwd: '/repo', + admissionExempt: true }) expect(getGitCalls()).not.toContain('git worktree prune') expect(getGitCalls()).toContain('git branch -d -- feature/test') diff --git a/src/main/git/worktree-delete-limit.test.ts b/src/main/git/worktree-delete-limit.test.ts new file mode 100644 index 000000000000..3c6292dccdec --- /dev/null +++ b/src/main/git/worktree-delete-limit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + WORKTREE_DELETE_CONCURRENCY, + _worktreeDeleteLimitSnapshotForTests, + runUnderWorktreeDeleteLimit +} from './worktree-delete-limit' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('runUnderWorktreeDeleteLimit', () => { + it('runs at most the limit at once and starts queued deletes in arrival order', async () => { + const gates = Array.from({ length: WORKTREE_DELETE_CONCURRENCY + 2 }, deferred) + const started: number[] = [] + const runs = gates.map((gate, index) => + runUnderWorktreeDeleteLimit(async () => { + started.push(index) + await gate.promise + }) + ) + await Promise.resolve() + + expect(started).toEqual([0, 1]) + expect(_worktreeDeleteLimitSnapshotForTests()).toEqual({ running: 2, waiting: 2 }) + + gates[1]?.resolve() + await runs[1] + await Promise.resolve() + expect(started).toEqual([0, 1, 2]) + + gates[0]?.resolve() + gates[2]?.resolve() + gates[3]?.resolve() + await Promise.all(runs) + expect(started).toEqual([0, 1, 2, 3]) + expect(_worktreeDeleteLimitSnapshotForTests()).toEqual({ running: 0, waiting: 0 }) + }) + + it('frees the slot when a delete fails', async () => { + await expect( + runUnderWorktreeDeleteLimit(async () => { + throw new Error('locked') + }) + ).rejects.toThrow('locked') + expect(_worktreeDeleteLimitSnapshotForTests()).toEqual({ running: 0, waiting: 0 }) + }) +}) diff --git a/src/main/git/worktree-delete-limit.ts b/src/main/git/worktree-delete-limit.ts new file mode 100644 index 000000000000..0304f6efe00b --- /dev/null +++ b/src/main/git/worktree-delete-limit.ts @@ -0,0 +1,34 @@ +/** + * Deletes `git worktree remove` runs at once on this host. + * + * Why its own limit instead of general git admission: a large delete holds its child for 20-35 s, + * and general admission can be as small as two slots, so two deletes there would stall every + * status read. Two concurrent deletes already saturate one disk; more only slow each other. + */ +export const WORKTREE_DELETE_CONCURRENCY = 2 + +let running = 0 +const waiting: (() => void)[] = [] + +export async function runUnderWorktreeDeleteLimit(operation: () => Promise): Promise { + if (running >= WORKTREE_DELETE_CONCURRENCY) { + await new Promise((resolve) => waiting.push(resolve)) + } else { + running += 1 + } + try { + return await operation() + } finally { + const next = waiting.shift() + if (next) { + // Why: hand the slot over directly so a new arrival cannot overtake a queued delete. + next() + } else { + running -= 1 + } + } +} + +export function _worktreeDeleteLimitSnapshotForTests(): { running: number; waiting: number } { + return { running, waiting: waiting.length } +} diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 00ccc34406fd..c1c21682f594 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -17,6 +17,7 @@ import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache' import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache' +import { runUnderWorktreeDeleteLimit } from './worktree-delete-limit' /** * Remove a worktree. @@ -66,26 +67,32 @@ async function performRemoveWorktree( // Why long paths: creation checks out with them on Windows, so deleting without them fails with // "Filename too long" (#6433) and leaves the branch behind via the Windows recovery. const longPathArgs = windowsLongPathGitArgs(repoPath) - const execOptions = { ...gitExecOptions(repoPath, options), ...removalGitEnv() } + const execOptions = { + ...gitExecOptions(repoPath, options), + ...removalGitEnv(), + admissionExempt: true as const + } const args = [...longPathArgs, 'worktree', 'remove'] if (force) { args.push('--force') } args.push(worktreePath) - try { - await gitExecFileAsync(args, execOptions) - } catch (error) { - if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { - throw error + await runUnderWorktreeDeleteLimit(async () => { + try { + await gitExecFileAsync(args, execOptions) + } catch (error) { + if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { + throw error + } + // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force. + await assertWorktreeCleanForRemoval(worktreePath, false, options) + await gitExecFileAsync( + [...longPathArgs, 'worktree', 'remove', '--force', worktreePath], + execOptions + ) } - // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force. - await assertWorktreeCleanForRemoval(worktreePath, false, options) - await gitExecFileAsync( - [...longPathArgs, 'worktree', 'remove', '--force', worktreePath], - execOptions - ) - } - await removeCheckoutLeftByGit(worktreePath, options) + await removeCheckoutLeftByGit(worktreePath, options) + }) if (!branchName) { return {} From 3d92074bbbe544a7cae60f1977d16d8c89053dd5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:05:26 -0700 Subject: [PATCH 05/46] fix(worktrees): delete removed checkouts in the background and mark them removing Since the checkout is deleted by `git worktree remove` in Git's own process, a large delete takes 20-35 s. Answering the request only after that made web and mobile (30 s), paired desktop (60/180 s) and the CLI (60 s) report a failure for a delete that was still going, and mobile silently re-showed the row. The request now does everything that can refuse (lock, cleanliness, archive hook, watcher/terminal gate, terminal stop, shared-link unlink), records the removal in an in-memory table on the host and answers `removing: true`. The delete, branch cleanup and metadata purge run after it in the same order as before, and the watcher/terminal gate stays held until they finish. - Listings mark rows in the table `removing` for clients that advertise `worktree.background-removal.v1` (the desktop renderer, paired desktop and web), and leave them out for everyone else (older clients, mobile, the CLI), which already dropped the row when the request answered. - The outcome (removed, with any preserved branch, or the error) rides the existing worktrees-changed event as an optional field, sent after the row has left the table. - A repeat delete while Git runs joins it. A create at the same path or with the same branch is refused with "Cleanup is pending; try again shortly"; create's name search skips the path, so generated names move on. - Nothing is persisted: after a quit or crash Git still lists the checkout and it can be deleted again. WSL checkouts still delete inline. - `orca worktree rm` says the checkout is still being deleted. --- src/cli/handlers/worktree.ts | 6 +- src/cli/index.test.ts | 14 ++ .../git/worktree-add-pending-removal.test.ts | 63 +++++ src/main/git/worktree-add.ts | 4 + src/main/ipc/worktree-remote.ts | 8 +- .../ipc/worktrees-background-removal.test.ts | 235 ++++++++++++++++++ .../ipc/worktrees-delete-pty-teardown.test.ts | 18 +- .../ipc/worktrees-removal-recovery.test.ts | 8 +- .../ipc/worktrees-test-background-removal.ts | 52 ++++ src/main/ipc/worktrees-test-harness.ts | 7 +- src/main/ipc/worktrees-test-runtime-stub.ts | 27 +- src/main/ipc/worktrees-windows.test.ts | 13 +- .../listing/detected-provider-listing.ts | 10 +- .../register-worktree-catalog-handlers.ts | 13 +- .../register-worktree-removal-handlers.ts | 9 + .../remove-registered-local-worktree.ts | 103 +++++++- src/main/repo-maintenance-idle-gate.ts | 2 + ...runtime-background-removal-test-support.ts | 47 ++++ .../orca-runtime-notify-ssh-state-changed.ts | 15 ++ .../orca-runtime-remove-managed-worktree.ts | 57 +++-- .../orca-runtime-test-lifecycle.spec.ts | 5 + ...removal-and-reconciliation-part-03.spec.ts | 16 +- .../rpc/methods/worktree-catalog-methods.ts | 41 +-- ...worktree-removal-marker-projection.test.ts | 109 ++++++++ .../rpc/worktree-removal-marker-projection.ts | 51 ++++ ...runtime-local-worktree-create-candidate.ts | 7 +- src/main/runtime/runtime-notifier-contract.ts | 7 +- ...ntime-registered-local-worktree-removal.ts | 67 ++++- src/main/window/runtime-window-lifecycle.ts | 8 +- src/main/worktree-background-removal.test.ts | 176 +++++++++++++ src/main/worktree-background-removal.ts | 162 ++++++++++++ src/shared/protocol-version.ts | 9 +- src/shared/runtime-client-events.ts | 3 +- src/shared/runtime-worktree-contracts.ts | 2 + src/shared/worktree/create-types.ts | 3 + src/shared/worktree/removal-outcome.ts | 13 + src/shared/worktree/types.ts | 3 + 37 files changed, 1296 insertions(+), 97 deletions(-) create mode 100644 src/main/git/worktree-add-pending-removal.test.ts create mode 100644 src/main/ipc/worktrees-background-removal.test.ts create mode 100644 src/main/ipc/worktrees-test-background-removal.ts create mode 100644 src/main/runtime/orca-runtime-background-removal-test-support.ts create mode 100644 src/main/runtime/rpc/worktree-removal-marker-projection.test.ts create mode 100644 src/main/runtime/rpc/worktree-removal-marker-projection.ts create mode 100644 src/main/worktree-background-removal.test.ts create mode 100644 src/main/worktree-background-removal.ts create mode 100644 src/shared/worktree/removal-outcome.ts diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 3541beef20b5..f74f6df79337 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -304,6 +304,10 @@ export const WORKTREE_HANDLERS: Record = { }) printHookWarning(result.result, json) printPreservedBranchWarning(result.result, json) - printResult(result, json, (value) => `removed: ${value.removed}`) + printResult(result, json, (value) => + value.removing + ? `removed: ${value.removed}\nOrca is still deleting the checkout in the background.` + : `removed: ${value.removed}` + ) } } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index bed4b9f8ab99..2a6c014b984e 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -120,6 +120,20 @@ describe('command aliases dispatch to the canonical handler', () => { ) }) + it('says the checkout is still being deleted when the host accepted a background removal', async () => { + queueFixtures( + callMock, + okFixture('req_show', { worktree: { hostId: 'local' } }), + okFixture('req', { removed: true, removing: true }) + ) + + await main(['worktree', 'rm', '--worktree', 'id:wt-1'], '/tmp/repo') + + expect(logSpy).toHaveBeenCalledWith( + 'removed: true\nOrca is still deleting the checkout in the background.' + ) + }) + it('fails closed when worktree removal cannot resolve a host', async () => { queueFixtures(callMock, okFixture('req_show', { worktree: { id: 'wt-1' } })) const priorExitCode = process.exitCode diff --git a/src/main/git/worktree-add-pending-removal.test.ts b/src/main/git/worktree-add-pending-removal.test.ts new file mode 100644 index 000000000000..70ab5c2bee39 --- /dev/null +++ b/src/main/git/worktree-add-pending-removal.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: gitExecFileSyncMock, + translateWslOutputPaths: translateWslOutputPathsMock +})) + +import { addWorktree } from './worktree' +import { registerWorktreeSuiteHooks } from './worktree-test-harness' +import { + _resetPendingWorktreeRemovalsForTests, + startBackgroundWorktreeRemoval +} from '../worktree-background-removal' + +registerWorktreeSuiteHooks() + +function startDeleting(): void { + startBackgroundWorktreeRemoval({ + removal: { + worktreeId: 'repo-1::/repo-feature', + repoId: 'repo-1', + repoPath: '/repo', + worktreePath: '/repo-feature', + branch: 'feature/test' + }, + run: () => new Promise(() => {}), + catalogVersion: () => ({ epoch: 'e', sequence: 1 }), + publish: () => {} + }) +} + +describe('addWorktree while Orca deletes a checkout in the background', () => { + afterEach(() => { + _resetPendingWorktreeRemovalsForTests() + }) + + it('refuses the same path with a clear message and runs no git', async () => { + startDeleting() + await expect(addWorktree('/repo', '/repo-feature', 'other-branch')).rejects.toThrow( + 'Orca is still deleting the workspace at /repo-feature. Cleanup is pending; try again shortly.' + ) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('refuses the same branch, which the delete removes when it finishes', async () => { + startDeleting() + await expect( + addWorktree('/repo', '/repo-elsewhere', 'feature/test', undefined, false, false, { + checkoutExistingBranch: true + }) + ).rejects.toThrow('Cleanup is pending; try again shortly.') + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index 380f3a3cc343..f7658f9c47f3 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -20,6 +20,7 @@ import type { } from './worktree-operation-options' import { gitExecOptions, resolveWorktreeAddTimeoutMs } from './worktree-operation-options' import { bumpWorktreeScanGeneration } from './worktree-scan-cache' +import { assertNoPendingWorktreeRemovalConflict } from '../worktree-background-removal' export type WorktreeAddBaseContext = AddWorktreeResult & { effectiveBase: string @@ -184,6 +185,9 @@ async function performAddWorktree( noCheckout = false, options: AddWorktreeOptions = {} ): Promise { + // Why: Git still owns that path and branch until the background delete finishes; a create now + // would race it, and the branch cleanup that follows would find the branch checked out again. + assertNoPendingWorktreeRemovalConflict(repoPath, { worktreePath, branch }) let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined // Why: enable long paths for this Windows checkout without changing user Git config. diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 767c6ece2460..af51cc37a00d 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -177,6 +177,7 @@ import { retireGeneratedWorktreeName } from '../worktree-name-retirement' import { createRetiredNameLookup } from '../../shared/worktree/retired-name-registry' +import { findPendingWorktreeRemovalConflict } from '../worktree-background-removal' const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000 const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512 @@ -2657,7 +2658,12 @@ async function performLocalWorktreeCreate( computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings, workspaceRoot), workspaceRoot ) - if (existsSync(worktreePath)) { + // Why the pending check: Git may already have deleted the directory while Orca still owns + // the path until its removal settles; take the next name instead of racing it. + if ( + existsSync(worktreePath) || + findPendingWorktreeRemovalConflict(repo.path, { worktreePath }) + ) { continue } diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts new file mode 100644 index 000000000000..123572699025 --- /dev/null +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + addWorktreeMock, + handleMock, + listWorktreesMock, + removeWorktreeMock +} from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { mockKnownFeatureWorktree } from './worktrees-test-fixtures' +import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests +} from '../worktree-background-removal' + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('../providers/ssh-git-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() +) +vi.mock('../providers/ssh-filesystem-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) + +type RawHandler = (event: unknown, args: unknown) => Promise> + +// The harness waits for the outcome the way the renderer does; these tests read the raw reply. +function rawRemoveHandler(): RawHandler { + const call = handleMock.mock.calls.findLast(([channel]) => channel === 'worktrees:remove') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ipcMain.handle's second argument is the registered handler. + return call?.[1] as RawHandler +} + +function blockGitRemove(): { release: (result?: object) => void; fail: (error: Error) => void } { + let release!: (result?: object) => void + let fail!: (error: Error) => void + removeWorktreeMock.mockImplementation( + () => + new Promise((resolve, reject) => { + release = (result = {}) => resolve(result) + fail = reject + }) + ) + return { + release: (result) => release(result), + fail: (error) => fail(error) + } +} + +const featureId = 'repo-1::/workspace/feature-wt' + +describe('worktrees:remove in the background', () => { + let runtimeStub: WorktreeRuntimeStub + + beforeEach(() => { + runtimeStub = setupWorktreeHandlers() + }) + + afterEach(() => { + _resetPendingWorktreeRemovalsForTests() + }) + + it('accepts before Git finishes and lists the row as removing until it does', async () => { + const worktrees = mockKnownFeatureWorktree() + const git = blockGitRemove() + + await expect(rawRemoveHandler()(null, { worktreeId: featureId })).resolves.toMatchObject({ + removing: true + }) + await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + + const during = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + removing?: true + }[] + expect(during.find((row) => row.id === featureId)?.removing).toBe(true) + expect(during.find((row) => row.id === 'repo-1::/workspace/repo')?.removing).toBeUndefined() + + listWorktreesMock.mockResolvedValue([worktrees[0]]) + git.release({ preservedBranch: { branchName: 'feature', head: 'feature' } }) + await _settlePendingWorktreeRemovalsForTests() + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(featureId, 'local') + expect(runtimeStub.removalOutcomes.get(featureId)).toMatchObject({ + status: 'removed', + preservedBranch: { branchName: 'feature', head: 'feature' } + }) + const after = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + }[] + expect(after.map((row) => row.id)).not.toContain(featureId) + }) + + it('publishes a failure after acceptance and lists the row normally again', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockKnownFeatureWorktree() + const git = blockGitRemove() + + await rawRemoveHandler()(null, { worktreeId: featureId }) + await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) + git.fail(new Error('permission denied')) + await _settlePendingWorktreeRemovalsForTests() + + expect(runtimeStub.removalOutcomes.get(featureId)).toMatchObject({ + status: 'failed', + error: expect.stringContaining('permission denied') + }) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + const rows = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { + id: string + removing?: true + }[] + expect(rows.find((row) => row.id === featureId)).toBeDefined() + expect(rows.find((row) => row.id === featureId)?.removing).toBeUndefined() + + // A retry is a fresh removal, not a join onto the failed one. + removeWorktreeMock.mockResolvedValue({}) + await expect(rawRemoveHandler()(null, { worktreeId: featureId })).resolves.toMatchObject({ + removing: true + }) + await _settlePendingWorktreeRemovalsForTests() + expect(removeWorktreeMock).toHaveBeenCalledTimes(2) + }) + + it('joins a repeat delete, with any options, onto the removal Git is running', async () => { + mockKnownFeatureWorktree() + const git = blockGitRemove() + + await rawRemoveHandler()(null, { worktreeId: featureId }) + await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) + await expect( + rawRemoveHandler()(null, { worktreeId: featureId, force: true, hostId: 'local' }) + ).resolves.toMatchObject({ removing: true }) + + git.release() + await _settlePendingWorktreeRemovalsForTests() + expect(removeWorktreeMock).toHaveBeenCalledTimes(1) + }) + + it('gives a create with the same name the next free name while the delete runs', async () => { + mockKnownFeatureWorktree() + blockGitRemove() + await rawRemoveHandler()(null, { worktreeId: featureId }) + await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) + + addWorktreeMock.mockResolvedValue({}) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/feature-wt-2', + head: 'abc', + branch: 'feature-wt-2', + isBare: false, + isMainWorktree: false + } + ]) + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'feature-wt' }) + + expect(addWorktreeMock).toHaveBeenCalledTimes(1) + expect(addWorktreeMock.mock.calls[0]?.[1]).toBe('/workspace/feature-wt-2') + }) +}) diff --git a/src/main/ipc/worktrees-delete-pty-teardown.test.ts b/src/main/ipc/worktrees-delete-pty-teardown.test.ts index 4326278446eb..e5ff8a2fbcae 100644 --- a/src/main/ipc/worktrees-delete-pty-teardown.test.ts +++ b/src/main/ipc/worktrees-delete-pty-teardown.test.ts @@ -145,7 +145,12 @@ describe('registerWorktreeHandlers', () => { ]) expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledTimes(1) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + // One publication when the row starts showing as removing, one with the outcome. + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('worktrees:changed', { + repoId: 'repo-1', + removalOutcome: expect.objectContaining({ status: 'removed' }) + }) }) it('rejects concurrent deletes for the same worktree id with different options', async () => { @@ -155,11 +160,13 @@ describe('registerWorktreeHandlers', () => { const started = new Promise((resolve) => { removalStarted = resolve }) - removeWorktreeMock.mockImplementation( + // Held in acceptance: once accepted, a repeat delete joins the background removal instead. + killAllProcessesForWorktreeMock.mockImplementation( () => - new Promise((resolve) => { + new Promise((resolve) => { removalStarted() - finishRemoval = resolve + finishRemoval = () => + resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) }) ) @@ -176,9 +183,10 @@ describe('registerWorktreeHandlers', () => { }) ).rejects.toThrow('Worktree deletion already in progress') - expect(removeWorktreeMock).toHaveBeenCalledTimes(1) + expect(removeWorktreeMock).not.toHaveBeenCalled() finishRemoval() await expect(first).resolves.toEqual({ catalogVersion: anyCatalogVersion }) + expect(removeWorktreeMock).toHaveBeenCalledTimes(1) }) it('still rejects forced unregistered delete paths that exist on disk', async () => { diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index d3231d50bd87..4706a717d36a 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -452,9 +452,11 @@ describe('registerWorktreeHandlers', () => { expect.anything() ) expect(store.removeWorktreeMeta).not.toHaveBeenCalled() - expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('worktrees:changed', { - repoId: 'repo-1' - }) + // The only publications are the accepted delete and its failure; nothing reports it removed. + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'worktrees:changed', + expect.objectContaining({ removalOutcome: expect.objectContaining({ status: 'removed' }) }) + ) } finally { removePathSpy.mockRestore() } diff --git a/src/main/ipc/worktrees-test-background-removal.ts b/src/main/ipc/worktrees-test-background-removal.ts new file mode 100644 index 000000000000..eab750e7ba70 --- /dev/null +++ b/src/main/ipc/worktrees-test-background-removal.ts @@ -0,0 +1,52 @@ +import { _settlePendingWorktreeRemovalsForTests } from '../worktree-background-removal' +import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' +import { handlers } from './worktrees-test-ipc-surface' + +type Handler = (event: unknown, args: unknown) => unknown + +/** Last background-removal outcome the harness runtime published, per worktree id. */ +export const harnessRemovalOutcomes = new Map() + +function readWorktreeId(args: unknown): string | undefined { + return typeof args === 'object' && args !== null && 'worktreeId' in args + ? String(args.worktreeId) + : undefined +} + +/** + * Registers an IPC handler on the harness. `worktrees:remove` resolves when its background delete + * ends, the way the renderer's delete flow does, so tests of what the delete does keep asserting + * on its finished state and error. + */ +export function registerHarnessHandler(channel: string, handler: Handler): void { + handlers[channel] = channel === 'worktrees:remove' ? waitForOutcome(handler) : handler +} + +function waitForOutcome(handler: Handler): Handler { + return async (event, args) => { + const accepted = await handler(event, args) + const worktreeId = readWorktreeId(args) + if ( + typeof accepted !== 'object' || + accepted === null || + !('removing' in accepted) || + !worktreeId + ) { + return accepted + } + await _settlePendingWorktreeRemovalsForTests() + const outcome = harnessRemovalOutcomes.get(worktreeId) + if (!outcome) { + return accepted + } + if (outcome.status === 'failed') { + throw new Error(outcome.error) + } + const { removing: _removing, ...rest } = accepted + return { + ...rest, + ...(outcome.preservedBranch ? { preservedBranch: outcome.preservedBranch } : {}), + ...(outcome.catalogVersion ? { catalogVersion: outcome.catalogVersion } : {}) + } + } +} diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index 6e1019d2150f..4ed588af9c2d 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -10,6 +10,7 @@ import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retir import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { createWorktreeRuntimeStub, type WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { handlers, mainWindow, store } from './worktrees-test-ipc-surface' +import { registerHarnessHandler } from './worktrees-test-background-removal' import { configureMetadataPruningStoreMocks } from './worktrees-test-metadata-pruning-store' import { resetWorktreeTestSshHostHome } from '../worktree-removal-test-ssh-host-home' import { @@ -192,9 +193,7 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { delete handlers[key] } - handleMock.mockImplementation((channel, handler) => { - handlers[channel] = handler - }) + handleMock.mockImplementation(registerHarnessHandler) store.getRepos.mockReturnValue([harnessRepo]) store.getRepo.mockReturnValue({ ...harnessRepo, worktreeBaseRef: null }) @@ -306,7 +305,7 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { // Default: no direct-read recovery, so a listing that omits the row still fails the create. describeCreatedWorktreeMock.mockResolvedValue(undefined) forceDeleteLocalBranchMock.mockResolvedValue(undefined) - const runtimeStub = createWorktreeRuntimeStub() + const runtimeStub = createWorktreeRuntimeStub(mainWindow) registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) return runtimeStub } diff --git a/src/main/ipc/worktrees-test-runtime-stub.ts b/src/main/ipc/worktrees-test-runtime-stub.ts index bb2f33cb05e5..3bfe3a56f26f 100644 --- a/src/main/ipc/worktrees-test-runtime-stub.ts +++ b/src/main/ipc/worktrees-test-runtime-stub.ts @@ -1,3 +1,6 @@ +import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' +import { _resetPendingWorktreeRemovalsForTests } from '../worktree-background-removal' +import { harnessRemovalOutcomes } from './worktrees-test-background-removal' import { vi } from 'vitest' export type WorktreeRuntimeStub = { @@ -18,10 +21,19 @@ export type WorktreeRuntimeStub = { closeFileWatchersForRemoval: ReturnType acquireFileWatcherRemoval: ReturnType hydrateInferredWorktreeLineage: ReturnType + publishWorktreeRemovalChange: ReturnType + /** Last background-removal outcome published per worktree id. */ + removalOutcomes: ReadonlyMap } /** Why: create-flow tests need a minimal runtime; full fetchRemoteWithCache behavior lives in fetch-remote-cache.test.ts. */ -export function createWorktreeRuntimeStub(): WorktreeRuntimeStub { +export function createWorktreeRuntimeStub(mainWindow?: { + webContents: { send: (...args: unknown[]) => unknown } +}): WorktreeRuntimeStub { + // Why here: every harness setup builds a stub, so no removal from an earlier test leaks in. + _resetPendingWorktreeRemovalsForTests() + const removalOutcomes = harnessRemovalOutcomes + removalOutcomes.clear() const runtimeStub: WorktreeRuntimeStub = { resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null), hasRemoteTrackingRef: vi.fn().mockResolvedValue(false), @@ -48,7 +60,18 @@ export function createWorktreeRuntimeStub(): WorktreeRuntimeStub { notifyWorktreesChangedForRemoteClients: vi.fn(), closeFileWatchersForRemoval: vi.fn().mockResolvedValue(undefined), acquireFileWatcherRemoval: vi.fn(), - hydrateInferredWorktreeLineage: vi.fn().mockResolvedValue(undefined) + hydrateInferredWorktreeLineage: vi.fn().mockResolvedValue(undefined), + // Mirrors the real runtime notifier, which sends worktrees:changed to the window. + publishWorktreeRemovalChange: vi.fn((repoId: string, outcome?: WorktreeRemovalOutcome) => { + if (outcome) { + removalOutcomes.set(outcome.worktreeId, outcome) + } + mainWindow?.webContents.send('worktrees:changed', { + repoId, + ...(outcome ? { removalOutcome: outcome } : {}) + }) + }), + removalOutcomes } runtimeStub.acquireFileWatcherRemoval.mockImplementation( async (worktreePath: string, connectionId?: string) => { diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index fb69743c288e..8b3347233d09 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -148,6 +148,7 @@ vi.mock('./worktree-logic', async (importOriginal) => { }) import { registerWorktreeHandlers } from './worktrees' +import { _settlePendingWorktreeRemovalsForTests } from '../worktree-background-removal' import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' type HandlerMap = Record unknown> @@ -311,7 +312,13 @@ describe('registerWorktreeHandlers – Windows path handling', () => { closeFileWatchersForRemoval: vi.fn().mockResolvedValue(undefined), acquireFileWatcherRemoval: vi.fn().mockResolvedValue({ finish: vi.fn().mockResolvedValue(undefined) - }) + }), + publishWorktreeRemovalChange: vi.fn((repoId: string, removalOutcome?: unknown) => + mainWindow.webContents.send('worktrees:changed', { + repoId, + ...(removalOutcome ? { removalOutcome } : {}) + }) + ) } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) @@ -583,6 +590,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::C:/workspaces/improve-dashboard' }) + await _settlePendingWorktreeRemovalsForTests() expect(assertWorktreeCleanForRemovalMock).toHaveBeenCalledWith(registeredWorktree.path, false) expect(removeWorktreeMock).toHaveBeenCalledWith( @@ -602,7 +610,8 @@ describe('registerWorktreeHandlers – Windows path handling', () => { 'repo-1::C:/workspaces/improve-dashboard' ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { - repoId: 'repo-1' + repoId: 'repo-1', + removalOutcome: expect.objectContaining({ status: 'removed' }) }) }) it('gives the issue-command runner the same setup shell as the setup runner', () => { diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 975cb5eeb783..3400c36ab0e8 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -1,3 +1,4 @@ +import { projectPendingWorktreeRemovals } from '../../../worktree-background-removal' import { getRepoExecutionHostId, getSshTargetIdForExecutionHost @@ -184,7 +185,14 @@ export async function listDetectedWorktreesForCapturedRepo( authoritative: true, source: 'git', catalogVersion: localWorktreeCatalogVersionAt(scan.generation), - worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta) + worktrees: connectionId + ? buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta) + : // Why always marked: the desktop renderer ships with this main process. + projectPendingWorktreeRemovals( + buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta), + (worktree) => worktree.id, + true + ) } } catch (err) { const aborted = abortedResult() diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index 89de29f30e84..94621a25ce41 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -32,11 +32,18 @@ import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { projectPendingWorktreeRemovals } from '../../../worktree-background-removal' import { getLocalWorktreeScanGeneration } from '../../../local-worktree-scan-generation' import { getRegisteredWorktreeRootsRevision } from '../../registered-worktree-roots-cache' const WORKTREE_LIST_ALL_CONCURRENCY = 8 +// Why always marked: the desktop renderer ships with this main process, so it reads the marker. +function markLocalWorktreesUnderRemoval(worktrees: T[]): T[] { + return projectPendingWorktreeRemovals(worktrees, (worktree) => worktree.id, true) +} + async function mapWithConcurrency( items: readonly T[], limit: number, @@ -152,9 +159,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) const metadata = metadataForRepo(repo) - return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) + const worktrees = buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) + return connectionId ? worktrees : markLocalWorktreesUnderRemoval(worktrees) } catch (err) { warnOnce( loggedWorktreeListFailures, @@ -244,9 +252,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) const metadata = allMeta ?? readAllWorktreeMetaForRepo(store, repo) - return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) + const worktrees = buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) + return connectionId ? worktrees : markLocalWorktreesUnderRemoval(worktrees) } catch (err) { warnOnce( loggedWorktreeListFailures, diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index edcc0899f730..801fed8ac947 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -12,6 +12,7 @@ import { getWorktreeRemovalOptionsKey } from './worktree-removal-coordinator' import { resolveRepoForExecutionHost } from '../repo-host-ownership' +import { isWorktreeRemovalPending } from '../../../worktree-background-removal' export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { const { store, options, worktreeRemovalsInFlight } = context @@ -26,6 +27,10 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo } // The resolved repo supplies host ownership when legacy callers omit args.hostId. const removalHostId = getRepoExecutionHostId(repo) + // Why: a retry or a second client asking while Git still deletes joins that removal. + if (isWorktreeRemovalPending(args.worktreeId, removalHostId)) { + return { removing: true, catalogVersion: getLocalWorktreeCatalogVersion(repoId) } + } const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) const optionsKey = getWorktreeRemovalOptionsKey(args) const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) @@ -53,6 +58,10 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) try { const result = await removal + if (result.removing) { + // The background job reports its own lifecycle when Git finishes. + return result + } options?.onWorktreeLifecycle?.({ kind: 'removed', worktreeId: args.worktreeId, diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index 5a36199c8e3d..057756622daa 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -1,7 +1,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' -import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../../../../shared/worktree/removal' import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' import { @@ -16,10 +16,7 @@ import { recoverLocalWindowsWorktreeRemoval } from '../../../local-worktree-remo import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety' import { CLIENT_REMOVAL_HOME } from '../../../worktree-removal-home-guard' -import { - cleanupUnusedWorktreePushTargetRemote, - notifyWorktreesChanged -} from '../../worktree-remote' +import { cleanupUnusedWorktreePushTargetRemote } from '../../worktree-remote' import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths @@ -43,6 +40,12 @@ import { stopPtysForDestructiveWorktreeRemoval } from './worktree-removal-ownership' import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { normalizeLocalBranchRef } from '../../../git/worktree-operation-options' +import { getLocalWorktreeCatalogVersion } from '../../../local-worktree-scan-generation' +import { + removesInBackground, + startBackgroundWorktreeRemoval +} from '../../../worktree-background-removal' export async function removeRegisteredLocalWorktree( context: WorktreeIpcContext, @@ -56,7 +59,7 @@ export async function removeRegisteredLocalWorktree( hasLocalWorktreeGitOptions: boolean, deleteBranch: boolean ): Promise { - const { mainWindow, store, runtime } = context + const { runtime } = context const refreshedWorktrees = hasLocalWorktreeGitOptions ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) : await listGitWorktreesStrict(repo.path) @@ -103,11 +106,10 @@ export async function removeRegisteredLocalWorktree( // Why: Git can still classify this as an orphan after preflight; keep strict PTY teardown before any recursive fallback deletion. } - let removalResult: RemoveWorktreeResult | undefined const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () => runtime.acquireFileWatcherRemoval(canonicalWorktreePath) ) - let removalCompleted = false + let accepted = false try { // Why: hold the watcher/terminal gate through Git and any recursive fallback so no late spawn recreates a native handle. // Linked-path deletion is destructive too, so PTYs must release every handle before Windows or WSL filesystem cleanup starts. @@ -121,7 +123,89 @@ export async function removeRegisteredLocalWorktree( if (linkedPaths.length > 0) { await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) } + accepted = true + } finally { + if (!accepted) { + await removalGate.finish(false) + } + } + const finish = (): Promise => + finishLocalWorktreeRemoval({ + context, + args, + repo, + repoId, + canonicalWorktreePath, + removalHostId, + removedPushTarget, + localWorktreeGitOptions, + hasLocalWorktreeGitOptions, + deleteBranch, + refreshedRegisteredWorktree, + removalGate + }) + if (!removesInBackground(canonicalWorktreePath, localWorktreeGitOptions)) { + const result = await finish() + runtime.publishWorktreeRemovalChange(repoId) + return result + } + // Why background: Git deletes the checkout in its own process for 20-35 s on a large tree; every + // refusal above already ran, and clients read the host's `removing` marker until it finishes. + startBackgroundWorktreeRemoval({ + removal: { + worktreeId: args.worktreeId, + repoId, + repoPath: repo.path, + worktreePath: canonicalWorktreePath, + branch: normalizeLocalBranchRef(refreshedRegisteredWorktree.branch) + }, + run: async () => { + const result = await finish() + context.options?.onWorktreeLifecycle?.({ + kind: 'removed', + worktreeId: args.worktreeId, + path: canonicalWorktreePath + }) + return result + }, + catalogVersion: () => getLocalWorktreeCatalogVersion(repoId), + publish: (outcome) => runtime.publishWorktreeRemovalChange(repoId, outcome) + }) + return { removing: true } +} + +async function finishLocalWorktreeRemoval({ + context, + args, + repo, + repoId, + canonicalWorktreePath, + removalHostId, + removedPushTarget, + localWorktreeGitOptions, + hasLocalWorktreeGitOptions, + deleteBranch, + refreshedRegisteredWorktree, + removalGate +}: { + context: WorktreeIpcContext + args: RemoveWorktreeArgs + repo: Repo + repoId: string + canonicalWorktreePath: string + removalHostId: ExecutionHostId + removedPushTarget: GitPushTarget | undefined + localWorktreeGitOptions: LocalProjectWorktreeGitOptions + hasLocalWorktreeGitOptions: boolean + deleteBranch: boolean + refreshedRegisteredWorktree: GitWorktreeInfo + removalGate: { finish: (removed: boolean) => Promise } +}): Promise { + const { store, runtime } = context + let removalResult: RemoveWorktreeResult | undefined + let removalCompleted = false + try { try { const removeOptions = { ...(!deleteBranch ? { deleteBranch } : {}), @@ -187,7 +271,6 @@ export async function removeRegisteredLocalWorktree( }) ) invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) removalCompleted = true return {} } else { @@ -228,7 +311,5 @@ export async function removeRegisteredLocalWorktree( await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { invalidateAuthorizedRootsCache() }) - - notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} } diff --git a/src/main/repo-maintenance-idle-gate.ts b/src/main/repo-maintenance-idle-gate.ts index 78bb25fe68c9..dec9acb549fb 100644 --- a/src/main/repo-maintenance-idle-gate.ts +++ b/src/main/repo-maintenance-idle-gate.ts @@ -6,6 +6,7 @@ import { } from './git/local-repo-ref-maintenance' import { hasWorktreeRemovalsInFlight } from './ipc/worktrees/worktree-ipc-context' import { hasPendingWorktreeCreatePreparations } from './worktree-create-preparation' +import { hasPendingWorktreeRemovals } from './worktree-background-removal' /** * The app-wide "not now" answer for idle repo maintenance. @@ -32,6 +33,7 @@ export function installRepoMaintenanceIdleGate( inputs.getWorkingAgentCount() > 0 || hasPendingWorktreeCreatePreparations() || hasWorktreeRemovalsInFlight() || + hasPendingWorktreeRemovals() || isOnBatteryPower() ) // Do-not-start, never stop-what-is-running. Killing a pack to honour a battery diff --git a/src/main/runtime/orca-runtime-background-removal-test-support.ts b/src/main/runtime/orca-runtime-background-removal-test-support.ts new file mode 100644 index 000000000000..3ba5e1e144f2 --- /dev/null +++ b/src/main/runtime/orca-runtime-background-removal-test-support.ts @@ -0,0 +1,47 @@ +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' +import { _settlePendingWorktreeRemovalsForTests } from '../worktree-background-removal' + +type RemovalRuntimePrototype = { + removeManagedWorktree: (...args: never[]) => Promise + publishWorktreeRemovalChange: (repoId: string, outcome?: WorktreeRemovalOutcome) => void +} + +/** + * Makes `removeManagedWorktree` resolve when its background delete ends, the way a client that + * waits for the outcome sees it, so suites about what the delete does keep asserting its end state. + */ +export function awaitBackgroundRemovalsInRuntimeTests(prototype: RemovalRuntimePrototype): void { + const remove = prototype.removeManagedWorktree + const publish = prototype.publishWorktreeRemovalChange + const outcomes: WorktreeRemovalOutcome[] = [] + prototype.publishWorktreeRemovalChange = function (repoId, outcome) { + if (outcome) { + outcomes.push(outcome) + } + return publish.call(this, repoId, outcome) + } + prototype.removeManagedWorktree = async function (...args) { + const startedAt = outcomes.length + const accepted = await remove.apply(this, args) + if (!accepted.removing) { + return accepted + } + await _settlePendingWorktreeRemovalsForTests() + const selector = String(args[0]) + const published = outcomes.slice(startedAt) + const outcome = + published.find((candidate) => selector.includes(candidate.worktreeId)) ?? published.at(-1) + if (!outcome) { + return accepted + } + if (outcome.status === 'failed') { + throw new Error(outcome.error) + } + const { removing: _removing, ...rest } = accepted + return { + ...rest, + ...(outcome.preservedBranch ? { preservedBranch: outcome.preservedBranch } : {}) + } + } +} diff --git a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts index 4a60ab458a46..1d801154d452 100644 --- a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts +++ b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts @@ -3,6 +3,8 @@ import { OrcaRuntimeWithGetStatus } from './orca-runtime-get-status' import type { SshConnectionState } from '../../shared/ssh-types' import { getPublicSshState } from './public-ssh-state' import { splitWorktreeId } from '../../shared/worktree/id' +import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' +import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' @@ -134,6 +136,19 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat this.emitClientEvent({ type: 'worktreesChanged', repoId }) } + /** A background removal started (no outcome) or ended; every client refetches, and the outcome + * rides the same event so the client that asked can finish its flow. */ + publishWorktreeRemovalChange(repoId: string, removalOutcome?: WorktreeRemovalOutcome): void { + this.invalidateResolvedWorktreeCache() + runWorktreeChangeInvalidators(repoId) + this.notifier?.worktreesChanged(repoId, undefined, removalOutcome) + this.emitClientEvent({ + type: 'worktreesChanged', + repoId, + ...(removalOutcome ? { removalOutcome } : {}) + }) + } + // Why: structural catalog changes require a fresh Git scan; renderer metadata edits do not. notifyWorktreeCatalogChangedForRemoteClients(repoId: string): void { this.invalidateWorktreeScanCacheForRepo(repoId) diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index 24e733046733..2b0658dad996 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -6,6 +6,7 @@ import { } from '../worktree-removal-repo-owner' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' +import { isWorktreeRemovalPending } from '../worktree-background-removal' import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' import { getRuntimeWorktreeRemovalOptionsKey, @@ -50,6 +51,16 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM const store = this.store const cleanupHostId = parseExecutionHostId(hostId)?.id const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId) + // Why: a retry or a second client asking while Git still deletes joins that removal. + if (isWorktreeRemovalPending(removalTarget.id, cleanupHostId)) { + return { removing: true } + } + const emitRemoved = (): void => + this.emitWorktreeLifecycle({ + kind: 'removed', + worktreeId: removalTarget.id, + path: removalTarget.path + }) const cleanupScopeKey = preservedBranchCleanupScopeKey({ worktreeId: removalTarget.id, hostId: cleanupHostId @@ -82,6 +93,14 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM } const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined const removalHostId = repo ? (cleanupHostId ?? getRepoExecutionHostId(repo)) : cleanupHostId + // Host state every removal path drops once Git has let go of the checkout. + const purgeRemovedWorktree = (): void => { + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) + invalidateAuthorizedRootsCache() + } const orphanOrFolderResult = await removeOrphanOrFolderWorktree({ runtime: this, store, @@ -140,12 +159,8 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM removalTarget.id ), finishRemoval: () => { - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) this.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() + purgeRemovedWorktree() this.notifyWorktreesChanged(repo.id) } }) @@ -192,11 +207,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM registeredWorktree.head, removedPushTarget ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() + purgeRemovedWorktree() this.notifyWorktreesChanged(repo.id) return removalResult ?? {} } @@ -235,11 +246,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM registeredWorktree.head, removedPushTarget ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() + purgeRemovedWorktree() this.notifyWorktreesChanged(repo.id) } }) @@ -277,24 +284,20 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM } else { this.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId) } - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - } + purgeRemovedWorktree() + }, + onRemoved: emitRemoved, + publish: (outcome) => this.publishWorktreeRemovalChange(repo.id, outcome) }) }) })() this.removeManagedWorktreeInFlight.track(cleanupScopeKey, optionsKey, removal) try { const result = await removal - this.emitWorktreeLifecycle({ - kind: 'removed', - worktreeId: removalTarget.id, - path: removalTarget.path - }) + // A background removal emits this when Git finishes instead. + if (!result.removing) { + emitRemoved() + } return result } finally { this.removeManagedWorktreeInFlight.release(cleanupScopeKey, removal) diff --git a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts index e815599728b2..4da17fe27a4e 100644 --- a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts +++ b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts @@ -1,4 +1,6 @@ import * as mocks from './orca-runtime-test-mocks.spec' +import { awaitBackgroundRemovalsInRuntimeTests } from './orca-runtime-background-removal-test-support' +import { _resetPendingWorktreeRemovalsForTests } from '../worktree-background-removal' const { MOCK_GIT_WORKTREES, RuntimeBrowserCommands, _resetTerminalViewAttributesForTest } = mocks const { addGitHubIssueCommentMock, addGitHubPRReviewCommentMock } = mocks @@ -57,7 +59,10 @@ const { sshProviderGenerations, unregisterSshGitProviderMock, updateGitHubIssueM const { updateGitHubPRDetailsMock, updateGitHubPRStateMock, updateGitHubPRTitleMock } = mocks const { updateGitLabIssueMock, updateGitLabMRMock, updateGitLabMRReviewersMock, vi } = mocks +awaitBackgroundRemovalsInRuntimeTests(mocks.OrcaRuntimeService.prototype as never) + function resetRuntimeTestMocks(): void { + _resetPendingWorktreeRemovalsForTests() // Why: constructing the browser commands is what pulls the Chromium cluster in, so // production installs this at the Electron entry. A Node host installs none and the // browser RPCs reject rather than silently succeeding. diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index a9edac0ede7a..e2d6b95a3469 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -273,7 +273,7 @@ describe('OrcaRuntimeService', () => { } }) - it('rejects concurrent runtime worktree removals for the same id with different options', async () => { + it('joins a runtime removal Git is already deleting, even with different options', async () => { const runtime = createWorktreeRemovalRuntime() const removeStarted = deferred() const finishRemoval = deferred() @@ -283,16 +283,18 @@ describe('OrcaRuntimeService', () => { return {} }) + const resolveTarget = vi.spyOn(runtime as never, 'resolveWorktreeRemovalTarget') const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID) await removeStarted.promise - await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })).rejects.toThrow( - 'Worktree deletion already in progress' - ) - - expect(removeWorktree).toHaveBeenCalledTimes(1) + // Accepted means every refusal already ran; a Force Delete retry has nothing left to waive. + const second = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true }) + await vi.waitFor(() => expect(resolveTarget).toHaveBeenCalledTimes(2)) + // The retry checks the removal table right after this resolves, before Git is let finish. + await resolveTarget.mock.results[1]?.value finishRemoval.resolve() - await expect(first).resolves.toEqual({}) + await expect(Promise.all([first, second])).resolves.toEqual([{}, {}]) + expect(removeWorktree).toHaveBeenCalledTimes(1) }) it('treats forced runtime deletion of an already-missing unregistered worktree as cleanup', async () => { diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index 8b230c169d8e..e7260d02f4a6 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -1,6 +1,10 @@ import { defineMethod } from '../core' import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { supportsWorktreeVisibilitySourceDefaults } from '../worktree-visibility-client-capability' +import { + projectWorktreeListRemovals, + projectWorktreePsRemovals +} from '../worktree-removal-marker-projection' import { WorktreeDetectedListParams, WorktreeListParams, @@ -12,12 +16,15 @@ export const WORKTREE_CATALOG_METHODS = [ name: 'worktree.ps', params: WorktreePsParams, handler: async (params, context) => { - const result = await context.runtime.getWorktreePs( - params.limit, - supportsWorktreeVisibilitySourceDefaults( - context, - params.supportsWorktreeVisibilitySourceDefaults - ) + const result = projectWorktreePsRemovals( + await context.runtime.getWorktreePs( + params.limit, + supportsWorktreeVisibilitySourceDefaults( + context, + params.supportsWorktreeVisibilitySourceDefaults + ) + ), + context ) // Why: callers that never send the field get the byte-exact legacy response. return params.afterSnapshotId === undefined @@ -29,10 +36,13 @@ export const WORKTREE_CATALOG_METHODS = [ name: 'worktree.list', params: WorktreeListParams, handler: async (params, context) => - context.runtime.listManagedWorktrees( - params.repo, - params.limit, - supportsWorktreeVisibilitySourceDefaults(context) + projectWorktreeListRemovals( + await context.runtime.listManagedWorktrees( + params.repo, + params.limit, + supportsWorktreeVisibilitySourceDefaults(context) + ), + context ) }), defineMethod({ @@ -44,10 +54,13 @@ export const WORKTREE_CATALOG_METHODS = [ name: 'worktree.detectedList', params: WorktreeDetectedListParams, handler: async (params, context) => - context.runtime.listDetectedManagedWorktrees( - params.repo, - undefined, - supportsWorktreeVisibilitySourceDefaults(context) + projectWorktreeListRemovals( + await context.runtime.listDetectedManagedWorktrees( + params.repo, + undefined, + supportsWorktreeVisibilitySourceDefaults(context) + ), + context ) }) ] diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts new file mode 100644 index 000000000000..625f16331199 --- /dev/null +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' +import { remoteRuntimeClientCapabilities } from '../../../shared/remote-runtime-client-capabilities' +import type { + RuntimeWorktreeListResult, + RuntimeWorktreePsResult +} from '../../../shared/runtime-worktree-contracts' +import { + _resetPendingWorktreeRemovalsForTests, + startBackgroundWorktreeRemoval +} from '../../worktree-background-removal' +import { + projectWorktreeListRemovals, + projectWorktreePsRemovals +} from './worktree-removal-marker-projection' + +const removingId = 'repo-1::/work/feature' +const keptId = 'repo-1::/work/other' + +function listResult(): RuntimeWorktreeListResult { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the projection reads only id, hostId and the counts. + return { + worktrees: [ + { id: removingId, hostId: 'local' }, + { id: keptId, hostId: 'local' } + ], + totalCount: 2, + truncated: false + } as unknown as RuntimeWorktreeListResult +} + +function psResult(): RuntimeWorktreePsResult { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the projection reads only worktreeId, hostId and the counts. + return { + worktrees: [ + { worktreeId: removingId, hostId: 'local' }, + { worktreeId: keptId, hostId: 'local' } + ], + totalCount: 2, + truncated: false + } as unknown as RuntimeWorktreePsResult +} + +describe('worktree listings while a checkout is being deleted', () => { + beforeEach(() => { + startBackgroundWorktreeRemoval({ + removal: { + worktreeId: removingId, + repoId: 'repo-1', + repoPath: '/work/repo', + worktreePath: '/work/feature', + branch: 'feature' + }, + run: () => new Promise(() => {}), + catalogVersion: () => ({ epoch: 'e', sequence: 1 }), + publish: () => {} + }) + }) + + afterEach(() => { + _resetPendingWorktreeRemovalsForTests() + }) + + it('marks the row for a desktop or web client that negotiated the marker', () => { + const context = { clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } + expect(projectWorktreeListRemovals(listResult(), context).worktrees).toEqual([ + { id: removingId, hostId: 'local', removing: true }, + { id: keptId, hostId: 'local' } + ]) + expect(projectWorktreePsRemovals(psResult(), context).worktrees).toEqual([ + { worktreeId: removingId, hostId: 'local', removing: true }, + { worktreeId: keptId, hostId: 'local' } + ]) + }) + + it('leaves the row out for a client that would show it as a normal workspace', () => { + // An older desktop or web build, the CLI, the phone, and an in-process caller alike. + for (const clientCapabilities of [ + [], + undefined, + NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + remoteRuntimeClientCapabilities() + ]) { + const list = projectWorktreeListRemovals(listResult(), { clientCapabilities }) + expect(list.worktrees.map((row) => row.id)).toEqual([keptId]) + expect(list.totalCount).toBe(1) + const ps = projectWorktreePsRemovals(psResult(), { clientCapabilities }) + expect(ps.worktrees.map((row) => row.worktreeId)).toEqual([keptId]) + expect(ps.totalCount).toBe(1) + } + }) + + it('advertises the marker only from the desktop renderer, never the shared remote defaults', () => { + // Mobile and the CLI send the shared defaults and have no Deleting affordance to show. + expect(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain( + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY + ) + expect(remoteRuntimeClientCapabilities()).not.toContain( + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY + ) + expect(NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES).not.toContain( + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY + ) + }) +}) diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.ts new file mode 100644 index 000000000000..a25204bce12b --- /dev/null +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.ts @@ -0,0 +1,51 @@ +import { WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { DetectedWorktreeListResult } from '../../../shared/worktree/types' +import type { + RuntimeWorktreeListResult, + RuntimeWorktreePsResult +} from '../../../shared/runtime-worktree-contracts' +import { projectPendingWorktreeRemovals } from '../../worktree-background-removal' +import type { RpcContext } from './core' + +// Why no in-process default: callers without negotiation (the CLI, host-side readers) print or act +// on rows, and a checkout mid-delete is not a workspace they can use. +function readsWorktreeRemovalMarker(context: Pick): boolean { + return ( + context.clientCapabilities?.includes(WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY) === true + ) +} + +export function projectWorktreeListRemovals< + T extends RuntimeWorktreeListResult | DetectedWorktreeListResult +>(result: T, context: Pick): T { + const worktrees = projectPendingWorktreeRemovals( + result.worktrees, + (worktree) => worktree.id, + readsWorktreeRemovalMarker(context) + ) + if (worktrees === result.worktrees) { + return result + } + const omitted = result.worktrees.length - worktrees.length + return 'totalCount' in result + ? { ...result, worktrees, totalCount: result.totalCount - omitted } + : { ...result, worktrees } +} + +export function projectWorktreePsRemovals( + result: RuntimeWorktreePsResult, + context: Pick +): RuntimeWorktreePsResult { + const worktrees = projectPendingWorktreeRemovals( + result.worktrees, + (summary) => summary.worktreeId, + readsWorktreeRemovalMarker(context) + ) + return worktrees === result.worktrees + ? result + : { + ...result, + worktrees, + totalCount: result.totalCount - (result.worktrees.length - worktrees.length) + } +} diff --git a/src/main/runtime/runtime-local-worktree-create-candidate.ts b/src/main/runtime/runtime-local-worktree-create-candidate.ts index aacf6ae0b495..7af0f8b99104 100644 --- a/src/main/runtime/runtime-local-worktree-create-candidate.ts +++ b/src/main/runtime/runtime-local-worktree-create-candidate.ts @@ -32,6 +32,7 @@ import { resolveCreateBranchName } from './runtime-worktree-create-git' import { runtimePathExists } from './runtime-worktree-filesystem' +import { findPendingWorktreeRemovalConflict } from '../worktree-background-removal' import type { RuntimeStore } from './runtime-store-contract' import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' @@ -189,7 +190,11 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: { computeWorktreePath(effectiveSanitizedName, args.repo.path, args.worktreePathSettings), args.workspaceRoot ) - if (!(await runtimePathExists(worktreePath))) { + // Why the pending check: Orca still owns this path until its background removal settles. + if ( + !findPendingWorktreeRemovalConflict(args.repo.path, { worktreePath }) && + !(await runtimePathExists(worktreePath)) + ) { worktreePathResolved = true break } diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index fa87d839cd5c..b322823d8597 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -8,6 +8,7 @@ import type { WorktreeRemoteBranchConflictEvent } from '../../shared/worktree/base-ref-drift-types' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeBrowserDriverState, @@ -25,7 +26,11 @@ export type RuntimeNotifier = { selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' } reason?: 'definition' | 'run' | 'usage' }): void - worktreesChanged(repoId: string, renamed?: { oldWorktreeId: string; newWorktreeId: string }): void + worktreesChanged( + repoId: string, + renamed?: { oldWorktreeId: string; newWorktreeId: string }, + removalOutcome?: WorktreeRemovalOutcome + ): void worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void reposChanged(): void diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index 011d30207fe2..9fa4db794083 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -26,6 +26,10 @@ import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' +import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' +import { normalizeLocalBranchRef } from '../git/worktree-operation-options' +import { getLocalWorktreeCatalogVersion } from '../local-worktree-scan-generation' +import { removesInBackground, startBackgroundWorktreeRemoval } from '../worktree-background-removal' export async function removeRuntimeRegisteredLocalWorktree(args: { repo: Repo @@ -54,6 +58,9 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { // Why: re-read after the archive hook, which can move the branch out from under the pre-hook row. fallbackHead: string | undefined ) => void + /** Fired when Git finished, not on acceptance. */ + onRemoved: () => void + publish: (outcome?: WorktreeRemovalOutcome) => void }): Promise { const { repo, registeredWorktree, localOptions } = args const canonicalPath = registeredWorktree.path @@ -121,14 +128,59 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { } } - let removalResult: RemoveWorktreeResult | undefined const gate = await args.acquireWatcherRemoval(canonicalPath) - let completed = false + let accepted = false try { await args.stopPtys() if (linkedPaths.length > 0) { await removeWorktreeLinkedPaths(canonicalPath, linkedPaths) } + accepted = true + } finally { + if (!accepted) { + await gate.finish(false) + } + } + const acceptedFields = { + ...(archiveHookOverride ? { archiveHookOverride } : {}), + ...(warning ? { warning } : {}) + } + if (!removesInBackground(canonicalPath, localOptions)) { + const result = await finishRuntimeLocalWorktreeRemoval(args, refreshed, gate) + args.publish() + return { ...result, ...acceptedFields } + } + // Why background: every refusal above already ran; Git's 20-35 s delete must not outlast client + // request timeouts, and clients read the host's `removing` marker until it finishes. + startBackgroundWorktreeRemoval({ + removal: { + worktreeId: args.target.id, + repoId: repo.id, + repoPath: repo.path, + worktreePath: canonicalPath, + branch: normalizeLocalBranchRef(refreshed.branch) + }, + run: async () => { + const result = await finishRuntimeLocalWorktreeRemoval(args, refreshed, gate) + args.onRemoved() + return result + }, + catalogVersion: () => getLocalWorktreeCatalogVersion(repo.id), + publish: (outcome) => args.publish(outcome) + }) + return { removing: true, ...acceptedFields } +} + +async function finishRuntimeLocalWorktreeRemoval( + args: Parameters[0], + refreshed: GitWorktreeInfo, + gate: { finish: (removed: boolean) => Promise } +): Promise { + const { repo, localOptions } = args + const canonicalPath = refreshed.path + let removalResult: RemoveWorktreeResult | undefined + let completed = false + try { try { removalResult = args.preserveBranchHead( await removeWorktree(repo.path, canonicalPath, args.force, { @@ -165,10 +217,7 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { await cleanupPushTarget(args) args.finishRemoval(undefined, false, refreshed.head) completed = true - return { - ...(archiveHookOverride ? { archiveHookOverride } : {}), - ...(warning ? { warning } : {}) - } + return {} } else { throw new Error(formatWorktreeRemovalError(error, canonicalPath, args.force)) } @@ -181,11 +230,7 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { } await cleanupPushTarget(args) args.finishRemoval(removalResult, true, refreshed.head) - return { - ...removalResult, - ...(archiveHookOverride ? { archiveHookOverride } : {}), - ...(warning ? { warning } : {}) - } + return removalResult ?? {} } async function cleanupPushTarget( diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 142389099c0f..1afcf351388b 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -36,10 +36,14 @@ export function registerRuntimeWindowLifecycle( }) const send = rendererNotifications.send runtime.setNotifier({ - worktreesChanged: (repoId, renamed) => { + worktreesChanged: (repoId, renamed, removalOutcome) => { // Why: clear scan caches before the renderer handles this event, so it can't read stale TTL entries after a mutation. runWorktreeChangeInvalidators(repoId) - send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }) + send('worktrees:changed', { + repoId, + ...(renamed ? { renamed } : {}), + ...(removalOutcome ? { removalOutcome } : {}) + }) }, worktreeBaseStatus: (event) => send('worktree:baseStatus', event), worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event), diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts new file mode 100644 index 000000000000..5132f20255b6 --- /dev/null +++ b/src/main/worktree-background-removal.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeRemovalOutcome } from '../shared/worktree/removal-outcome' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests, + assertNoPendingWorktreeRemovalConflict, + isWorktreeRemovalPending, + projectPendingWorktreeRemovals, + removesInBackground, + startBackgroundWorktreeRemoval +} from './worktree-background-removal' + +const removal = { + worktreeId: 'repo-1::/work/feature', + repoId: 'repo-1', + repoPath: '/work/repo', + worktreePath: '/work/feature', + branch: 'feature' +} +const catalogVersion = { epoch: 'e', sequence: 7 } + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('background worktree removal', () => { + afterEach(() => { + _resetPendingWorktreeRemovalsForTests() + }) + + it('accepts before Git finishes and publishes the outcome after the row leaves the table', async () => { + const git = deferred<{ preservedBranch: { branchName: string; head: string } }>() + const published: { outcome?: WorktreeRemovalOutcome; pendingAtPublish: boolean }[] = [] + startBackgroundWorktreeRemoval({ + removal, + run: () => git.promise, + catalogVersion: () => catalogVersion, + publish: (outcome) => + published.push({ outcome, pendingAtPublish: isWorktreeRemovalPending(removal.worktreeId) }) + }) + + expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(true) + expect(published).toEqual([{ outcome: undefined, pendingAtPublish: true }]) + + git.resolve({ preservedBranch: { branchName: 'feature', head: 'abc' } }) + await _settlePendingWorktreeRemovalsForTests() + + expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) + // A refetch the outcome triggers must not see the row as still removing. + expect(published[1]).toEqual({ + outcome: { + worktreeId: removal.worktreeId, + status: 'removed', + preservedBranch: { branchName: 'feature', head: 'abc' }, + catalogVersion + }, + pendingAtPublish: false + }) + }) + + it('publishes a failure and clears the row so a retry starts over', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const publish = vi.fn() + startBackgroundWorktreeRemoval({ + removal, + run: async () => { + throw new Error('Failed to delete worktree at /work/feature. Permission denied') + }, + catalogVersion: () => catalogVersion, + publish + }) + await _settlePendingWorktreeRemovalsForTests() + + expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) + expect(publish).toHaveBeenLastCalledWith({ + worktreeId: removal.worktreeId, + status: 'failed', + error: 'Failed to delete worktree at /work/feature. Permission denied' + }) + }) + + it('keeps the table consistent when publishing throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + startBackgroundWorktreeRemoval({ + removal, + run: async () => ({}), + catalogVersion: () => catalogVersion, + publish: () => { + throw new Error('window gone') + } + }) + await _settlePendingWorktreeRemovalsForTests() + expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) + }) + + it('refuses a create at the same path or branch while Git deletes', async () => { + const git = deferred>() + startBackgroundWorktreeRemoval({ + removal, + run: () => git.promise, + catalogVersion: () => catalogVersion, + publish: () => {} + }) + + expect(() => + assertNoPendingWorktreeRemovalConflict('/work/repo', { worktreePath: '/work/feature' }) + ).toThrow('Cleanup is pending; try again shortly.') + expect(() => + assertNoPendingWorktreeRemovalConflict('/work/repo', { branch: 'refs/heads/feature' }) + ).toThrow('Cleanup is pending; try again shortly.') + expect(() => + assertNoPendingWorktreeRemovalConflict('/work/other-repo', { branch: 'feature' }) + ).not.toThrow() + expect(() => + assertNoPendingWorktreeRemovalConflict('/work/repo', { + worktreePath: '/work/feature-2', + branch: 'feature-2' + }) + ).not.toThrow() + + git.resolve({}) + await _settlePendingWorktreeRemovalsForTests() + expect(() => + assertNoPendingWorktreeRemovalConflict('/work/repo', { worktreePath: '/work/feature' }) + ).not.toThrow() + }) + + it('answers only for this host: a same-id row on an SSH host is not being removed here', () => { + startBackgroundWorktreeRemoval({ + removal, + run: () => new Promise(() => {}), + catalogVersion: () => catalogVersion, + publish: () => {} + }) + expect(isWorktreeRemovalPending(removal.worktreeId, 'local')).toBe(true) + expect(isWorktreeRemovalPending(removal.worktreeId, 'ssh:box')).toBe(false) + }) + + it('marks rows for clients that read the marker and omits them for clients that do not', () => { + startBackgroundWorktreeRemoval({ + removal, + run: () => new Promise(() => {}), + catalogVersion: () => catalogVersion, + publish: () => {} + }) + const rows = [ + { id: removal.worktreeId, hostId: 'local' as const }, + { id: 'repo-1::/work/other' }, + { id: removal.worktreeId, hostId: 'ssh:box' as const } + ] + + expect(projectPendingWorktreeRemovals(rows, (row) => row.id, true)).toEqual([ + { id: removal.worktreeId, hostId: 'local', removing: true }, + { id: 'repo-1::/work/other' }, + { id: removal.worktreeId, hostId: 'ssh:box' } + ]) + expect(projectPendingWorktreeRemovals(rows, (row) => row.id, false)).toEqual([ + { id: 'repo-1::/work/other' }, + { id: removal.worktreeId, hostId: 'ssh:box' } + ]) + }) + + it('keeps WSL checkouts on the inline delete', () => { + expect(removesInBackground('/work/feature', {})).toBe(true) + expect(removesInBackground('/home/me/feature', { wslDistro: 'Ubuntu' })).toBe(false) + }) + + it('returns listings untouched when nothing is being removed', () => { + const rows = [{ id: removal.worktreeId }] + expect(projectPendingWorktreeRemovals(rows, (row) => row.id, false)).toBe(rows) + }) +}) diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts new file mode 100644 index 000000000000..1210584ed68b --- /dev/null +++ b/src/main/worktree-background-removal.ts @@ -0,0 +1,162 @@ +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' +import type { RemoveWorktreeResult } from '../shared/worktree/create-types' +import type { WorktreeCatalogVersion } from '../shared/worktree/catalog-version' +import type { WorktreeRemovalOutcome } from '../shared/worktree/removal-outcome' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' +import { parseWslPath } from './wsl' + +export type PendingWorktreeRemoval = { + worktreeId: string + repoId: string + repoPath: string + worktreePath: string + /** Short branch name the checkout had; empty when detached. */ + branch: string +} + +// Why in memory only: after a crash Git still lists the checkout, so it returns as a normal row +// and a second remove finishes it. Nothing here outlives the process that runs the delete. +const pendingByWorktreeId = new Map() +const runningJobs = new Set>() + +/** Only this host's local checkouts are removed in the background. */ +export function isWorktreeRemovalPending(worktreeId: string, hostId?: ExecutionHostId): boolean { + return (hostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID + ? pendingByWorktreeId.has(worktreeId) + : false +} + +/** WSL checkouts still delete inline, as before; moving them off the request is a follow-up. */ +export function removesInBackground( + worktreePath: string, + options: { wslDistro?: string } +): boolean { + return !options.wslDistro && !parseWslPath(worktreePath) +} + +export function hasPendingWorktreeRemovals(): boolean { + return pendingByWorktreeId.size > 0 +} + +export function findPendingWorktreeRemovalConflict( + repoPath: string, + target: { worktreePath?: string; branch?: string } +): PendingWorktreeRemoval | undefined { + const branch = target.branch?.replace(/^refs\/heads\//, '') + for (const removal of pendingByWorktreeId.values()) { + if (!areWorktreePathsEqual(removal.repoPath, repoPath)) { + continue + } + if ( + (target.worktreePath && areWorktreePathsEqual(removal.worktreePath, target.worktreePath)) || + (branch && removal.branch === branch) + ) { + return removal + } + } + return undefined +} + +export function assertNoPendingWorktreeRemovalConflict( + repoPath: string, + target: { worktreePath?: string; branch?: string } +): void { + const removal = findPendingWorktreeRemovalConflict(repoPath, target) + if (removal) { + throw new Error( + `Orca is still deleting the workspace at ${removal.worktreePath}. Cleanup is pending; try again shortly.` + ) + } +} + +/** + * Records an accepted removal and runs its delete off the request. `publish` fires once when the + * row starts showing as removing, and once with the outcome after the row has left the table. + */ +export function startBackgroundWorktreeRemoval(args: { + removal: PendingWorktreeRemoval + run: () => Promise + catalogVersion: () => WorktreeCatalogVersion + publish: (outcome?: WorktreeRemovalOutcome) => void +}): void { + const { removal } = args + pendingByWorktreeId.set(removal.worktreeId, removal) + const job = settleBackgroundWorktreeRemoval(args) + runningJobs.add(job) + void job.finally(() => runningJobs.delete(job)) + publishSafely(args.publish, undefined) +} + +async function settleBackgroundWorktreeRemoval( + args: Parameters[0] +): Promise { + const { removal } = args + let outcome: WorktreeRemovalOutcome + try { + const result = await args.run() + outcome = { + worktreeId: removal.worktreeId, + status: 'removed', + ...(result.preservedBranch ? { preservedBranch: result.preservedBranch } : {}), + catalogVersion: args.catalogVersion() + } + } catch (error) { + console.warn(`[worktrees] background removal of ${removal.worktreePath} failed`, error) + outcome = { + worktreeId: removal.worktreeId, + status: 'failed', + error: error instanceof Error ? error.message : String(error) + } + } + if (pendingByWorktreeId.get(removal.worktreeId) === removal) { + pendingByWorktreeId.delete(removal.worktreeId) + } + publishSafely(args.publish, outcome) +} + +function publishSafely( + publish: (outcome?: WorktreeRemovalOutcome) => void, + outcome: WorktreeRemovalOutcome | undefined +): void { + try { + publish(outcome) + } catch (error) { + // Why: a failed notification must not strand the table entry or reject the detached job. + console.error('[worktrees] failed to publish background removal state', error) + } +} + +/** + * Marks rows whose checkout this host is deleting, or leaves them out for a client that cannot + * read the marker: such a client already dropped the row on acceptance and would re-show it. + */ +export function projectPendingWorktreeRemovals< + T extends { hostId?: ExecutionHostId; removing?: true } +>(rows: T[], idOf: (row: T) => string, clientReadsMarker: boolean): T[] { + if (pendingByWorktreeId.size === 0) { + return rows + } + const projected: T[] = [] + for (const row of rows) { + const pending = + (row.hostId === undefined || row.hostId === LOCAL_EXECUTION_HOST_ID) && + pendingByWorktreeId.has(idOf(row)) + if (!pending) { + projected.push(row) + } else if (clientReadsMarker) { + projected.push({ ...row, removing: true }) + } + } + return projected +} + +export async function _settlePendingWorktreeRemovalsForTests(): Promise { + while (runningJobs.size > 0) { + await Promise.all(runningJobs) + } +} + +export function _resetPendingWorktreeRemovalsForTests(): void { + pendingByWorktreeId.clear() + runningJobs.clear() +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 3a877a809004..bcb2d2ecadb0 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -257,6 +257,11 @@ export const WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-defaults.v1' as const export const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-source-defaults.v1' as const +// Why: the host answers worktree.rm on acceptance and deletes in the background while Git still +// lists the checkout. A client that cannot read `removing` would re-show that row as a normal +// workspace, so the host leaves rows under removal out of listings for clients without this. +export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY = + 'worktree.background-removal.v1' as const // Why: older hosts drop automation.list's selector and answer with the whole authority, so a scoped client must not read that as one host's rows. export const AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY = 'automation.list-host-scope.v1' as const @@ -317,7 +322,9 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. - SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + // Only the renderer shows Deleting for a `removing` row; CLI and mobile get those rows omitted. + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY ] as const export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index a9d92b5a57d8..cea4f40a9db3 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -1,4 +1,5 @@ import type { CreateWorktreeResult } from './worktree/create-types' +import type { WorktreeRemovalOutcome } from './worktree/removal-outcome' import type { WorktreeDefaultTabsLaunch, WorktreeSetupLaunch, @@ -10,7 +11,7 @@ import type { RuntimeNativeChatLaunchDraftResolution } from './runtime-types' export type RuntimeClientEvent = | { type: 'reposChanged' } - | { type: 'worktreesChanged'; repoId: string } + | { type: 'worktreesChanged'; repoId: string; removalOutcome?: WorktreeRemovalOutcome } | ({ type: 'nativeChatLaunchDraftResolved' } & RuntimeNativeChatLaunchDraftResolution) | { type: 'terminalSideEffects'; batch: TerminalSideEffectBatch } // Why: SSH connections live on the runtime host; paired clients have no IPC diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index 3d95fb0a3dc9..68e83c3268ca 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -76,6 +76,8 @@ export type RuntimeWorktreePsSummary = { /** Optional discriminator for a working workspace; older clients fall back to ordinary working. */ workingMode?: AgentWorkingMode agents: RuntimeWorktreeAgentRow[] + /** See `Worktree.removing`; sent only to clients that advertise background removal. */ + removing?: true } export type RuntimeGitLocalBranches = { diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index 97f268d5b08a..deffe2cfff40 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -215,6 +215,9 @@ export type RemoveWorktreeResult = { preservedBranch?: PreservedWorktreeBranch /** Present only when a FAILED archive hook was explicitly waived for this removal (#19334). */ archiveHookOverride?: ArchiveHookOverride + /** The host accepted the removal and deletes in the background; the result arrives as the + * `removalOutcome` of a later worktrees-changed event. Older hosts omit it and reply when done. */ + removing?: true } export type ForceDeleteWorktreeBranchResult = { diff --git a/src/shared/worktree/removal-outcome.ts b/src/shared/worktree/removal-outcome.ts new file mode 100644 index 000000000000..6b88242fa999 --- /dev/null +++ b/src/shared/worktree/removal-outcome.ts @@ -0,0 +1,13 @@ +import type { WorktreeCatalogVersion } from './catalog-version' +import type { PreservedWorktreeBranch } from './create-types' + +/** How a background worktree removal ended, published once on the worktrees-changed event. */ +export type WorktreeRemovalOutcome = + | { + worktreeId: string + status: 'removed' + preservedBranch?: PreservedWorktreeBranch + /** The catalog the finished removal produced. */ + catalogVersion?: WorktreeCatalogVersion + } + | { worktreeId: string; status: 'failed'; error: string } diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts index b7f02585a084..ce2bd025e28c 100644 --- a/src/shared/worktree/types.ts +++ b/src/shared/worktree/types.ts @@ -143,6 +143,9 @@ export type Worktree = { mobileDiffReview?: MobileDiffReviewState automationProvenance?: AutomationWorkspaceProvenance cliProvenance?: CliWorkspaceProvenance + /** The host is deleting this checkout in the background; Git lists it until that finishes. + * Sent only to clients that advertise `worktree.background-removal.v1`. */ + removing?: true } & GitWorktreeInfo /** Provenance for workspaces created through `orca worktree create`. Absent on From f4ef5bc4f9323aeff1102d5b25e0918a7e8ec866 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:05:42 -0700 Subject: [PATCH 06/46] fix(worktrees): keep the existing Deleting card until the host's Git finishes The host now answers a local worktree delete on acceptance and deletes in the background. The renderer keeps the existing delete state set until the host publishes how it ended: - The delete that asked waits for the outcome on the worktrees-changed event (local IPC or the paired runtime's client event), then runs the same teardown, preserved-branch toast and card error an inline delete did. If that event is lost to a dropped connection, a listing that shows the row gone after it was marked removing finishes the wait, and one that shows it back without the marker fails it. - Any other renderer (a reload, a paired desktop, web) sets the same delete state from the host's `removing` marker and clears it when the marker goes. A failure the host publishes lands on that card's existing error. - Web advertises `worktree.background-removal.v1` so the host sends it the marker; paired desktop does through the Electron capability list. No new component, style or state: the card reads the delete state it always did. A host that predates this answers when done without `removing`, and the renderer takes that as finished, as before. --- src/preload/api/worktree-api.ts | 10 +- src/preload/api/worktrees-bridge.ts | 15 +- .../ipc-events/app-lifetime-ipc-bridge.ts | 2 + .../background-worktree-removal-bridge.ts | 126 +++++++++++++ .../ipc-events/project-catalog-ipc-bridge.ts | 10 +- .../ipc-events/runtime-client-ipc-bridge.ts | 7 + .../worktrees-background-removal.test.ts | 169 ++++++++++++++++++ .../teardown/background-worktree-removal.ts | 164 +++++++++++++++++ .../teardown/dispatch-worktree-removal.ts | 18 ++ ...eb-runtime-connection-frame-router.test.ts | 11 +- .../web-runtime-connection-frame-router.ts | 4 +- 11 files changed, 520 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts create mode 100644 src/renderer/src/store/slices/worktrees-background-removal.test.ts create mode 100644 src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 115ee66d9a29..b0aa2ab9236a 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -9,6 +9,7 @@ import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' import type { ExecutionHostId } from '../../shared/execution-host' +import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import type { RetiredNameRegistry } from '../../shared/worktree/retired-name-registry' import type { FolderWorkspacePathStatus, @@ -134,7 +135,14 @@ export type WorktreeApi = { /** Full CLI output of the last branch auto-rename generation failure, held * in main memory only — null after a restart or once the failure clears. */ getBranchRenameFailureOutput: (args: { worktreeId: string }) => Promise - onChanged: (callback: (data: { repoId: string }) => void) => () => void + onChanged: ( + callback: (data: { + repoId: string + renamed?: { oldWorktreeId: string; newWorktreeId: string } + /** Set when a background removal on this host ended. */ + removalOutcome?: WorktreeRemovalOutcome + }) => void + ) => () => void onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void) => () => void onHeadIdentitiesChanged: ( callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index 8fb8d5728ac9..1bc7f935ba7d 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -10,6 +10,8 @@ import type { import type { WorktreeHeadIdentity } from '../../shared/worktree/types' import type { PreloadApi } from '../api-types' +type WorktreesChangedEvent = Parameters[0]>[0] + export const worktreesApi = { list: (args) => ipcRenderer.invoke('worktrees:list', args), listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args), @@ -68,16 +70,9 @@ export const worktreesApi = { getBranchRenameFailureOutput: (args) => ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args), - onChanged: ( - callback: (data: { - repoId: string - renamed?: { oldWorktreeId: string; newWorktreeId: string } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { repoId: string; renamed?: { oldWorktreeId: string; newWorktreeId: string } } - ) => callback(data) + onChanged: (callback: (data: WorktreesChangedEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: WorktreesChangedEvent) => + callback(data) ipcRenderer.on('worktrees:changed', listener) return () => ipcRenderer.removeListener('worktrees:changed', listener) }, diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index c5964d77feca..a02b3fc40c7f 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -6,6 +6,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { resetAgentHookCompletionNotificationCoordinators } from '../agent-hook-completion-notifications' import { useAppStore } from '../../store' import { registerAgentStatusIpcBridge } from './agent-status-ipc-bridge' +import { registerBackgroundWorktreeRemovalBridge } from './background-worktree-removal-bridge' import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge' import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' import { registerContentCreationIpcBridge } from './content-creation-ipc-bridge' @@ -83,6 +84,7 @@ export function installAppLifetimeIpcEvents( .catch((error) => console.error('Failed to read runtime status snapshots:', error)) } const unsubscribeRuntimeEnvironmentStore = registerRuntimeClientIpcBridge(unsubs, worktreeRuntime) + registerBackgroundWorktreeRemovalBridge(unsubs) registerProjectCatalogIpcBridge( unsubs, worktreeRuntime.worktreeChangeRefreshQueue, diff --git a/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts b/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts new file mode 100644 index 000000000000..111d31f18374 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts @@ -0,0 +1,126 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import type { WorktreeRemovalOutcome } from '../../../../shared/worktree/removal-outcome' +import type { Worktree } from '../../../../shared/worktree/types' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import { getDeleteStateForWorktreeHost } from '@/components/sidebar/worktree-delete-state-host-match' +import { useAppStore } from '../../store' +import type { AppState } from '../../store/types' +import { + rowHostId, + setBackgroundWorktreeRemovalRowsLookup, + settleBackgroundWorktreeRemoval, + settleBackgroundWorktreeRemovalsFromRows +} from '../../store/slices/worktrees/teardown/background-worktree-removal' + +type HostMarkedRow = Pick +type AppStoreApi = Pick + +// Delete states this bridge set from a host marker, keyed like deleteStateByWorktreeId. A state the +// local delete flow set is left to that flow. +const hostMarkedDeleteStates = new Map() + +function deleteStateKey(row: HostMarkedRow): string { + return row.hostId ? getWorktreeHostIdentity(row) : row.id +} + +function rowsForWorktree(state: AppState, worktreeId: string): Worktree[] { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return [ + ...(state.worktreesByRepo[repoId] ?? []), + ...(state.detectedWorktreesByRepo[repoId]?.worktrees ?? []) + ] +} + +/** Keeps the existing Deleting card state set while the host lists a row as removing. */ +export function reconcileHostWorktreeRemovals(store: AppStoreApi = useAppStore): void { + const state = store.getState() + settleBackgroundWorktreeRemovalsFromRows((worktreeId) => rowsForWorktree(state, worktreeId)) + const marked: HostMarkedRow[] = [] + const seen = new Set() + for (const rows of Object.values(state.worktreesByRepo)) { + for (const row of rows) { + if (!row.removing) { + continue + } + const key = deleteStateKey(row) + seen.add(key) + if (hostMarkedDeleteStates.has(key)) { + continue + } + const current = getDeleteStateForWorktreeHost(row, state.deleteStateByWorktreeId) + if (current?.isDeleting && current.phase !== 'queued') { + continue + } + hostMarkedDeleteStates.set(key, { id: row.id, hostId: row.hostId }) + marked.push({ id: row.id, hostId: row.hostId }) + } + } + if (marked.length > 0) { + state.markWorktreesDeleting(marked) + } + for (const [key, row] of hostMarkedDeleteStates) { + if (seen.has(key)) { + continue + } + hostMarkedDeleteStates.delete(key) + if (store.getState().deleteStateByWorktreeId[key]?.isDeleting) { + store.getState().clearWorktreeDeleteState(row.id, row.hostId) + } + } +} + +/** Routes a host's removal outcome to the delete that asked for it, or onto a host-marked card. */ +export function applyBackgroundWorktreeRemovalOutcome( + hostId: ExecutionHostId, + outcome: WorktreeRemovalOutcome, + store: AppStoreApi = useAppStore +): void { + if (settleBackgroundWorktreeRemoval(hostId, outcome) || outcome.status !== 'failed') { + return + } + for (const [key, row] of hostMarkedDeleteStates) { + if (row.id !== outcome.worktreeId || rowHostId(row) !== hostId) { + continue + } + hostMarkedDeleteStates.delete(key) + store.setState((s) => ({ + deleteStateByWorktreeId: { + ...s.deleteStateByWorktreeId, + [key]: { + isDeleting: false, + ...(row.hostId ? { executionHostId: row.hostId } : {}), + error: outcome.error, + canForceDelete: false, + forceDeleteReason: null + } + } + })) + } +} + +export function registerBackgroundWorktreeRemovalBridge(unsubs: (() => void)[]): void { + setBackgroundWorktreeRemovalRowsLookup((worktreeId) => + rowsForWorktree(useAppStore.getState(), worktreeId) + ) + unsubs.push(() => setBackgroundWorktreeRemovalRowsLookup(null)) + let previousRows = useAppStore.getState().worktreesByRepo + let previousDetected = useAppStore.getState().detectedWorktreesByRepo + unsubs.push( + useAppStore.subscribe((state) => { + if ( + state.worktreesByRepo === previousRows && + state.detectedWorktreesByRepo === previousDetected + ) { + return + } + previousRows = state.worktreesByRepo + previousDetected = state.detectedWorktreesByRepo + reconcileHostWorktreeRemovals() + }) + ) +} + +export function _resetBackgroundWorktreeRemovalBridgeForTests(): void { + hostMarkedDeleteStates.clear() +} diff --git a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts index 6c75e473725d..6e0e38fcfe0a 100644 --- a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts @@ -1,3 +1,6 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { WorktreeRemovalOutcome } from '../../../../shared/worktree/removal-outcome' +import { applyBackgroundWorktreeRemovalOutcome } from './background-worktree-removal-bridge' import { applyWorktreeHeadIdentities } from '../worktree-head-identity-apply' import type { WorktreeChangeRefreshQueue } from '../worktree-change-refresh-queue' import { useAppStore } from '../../store' @@ -33,13 +36,18 @@ export function registerProjectCatalogIpcBridge( async (data: { repoId: string renamed?: { oldWorktreeId: string; newWorktreeId: string } + removalOutcome?: WorktreeRemovalOutcome }) => { + const { removalOutcome, ...change } = data + if (removalOutcome) { + applyBackgroundWorktreeRemovalOutcome(LOCAL_EXECUTION_HOST_ID, removalOutcome) + } // Why: preserve this event's local origin across queue delays and runtime // focus changes; otherwise an unbound repo can refresh from the wrong host. // A folder rename changes the worktree id; handleWorktreesChanged re-keys // state and shields it from the deletion diff. worktreeChangeRefreshQueue.enqueue({ - ...data, + ...change, forceLocalOwner: true }) } diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index ae014d702300..cc3be8894c15 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -28,6 +28,7 @@ import { invalidateRuntimeClientEventReplay } from './runtime-environment-subscription-selection' import type { WorktreeEventRuntime } from './worktree-event-runtime' +import { applyBackgroundWorktreeRemovalOutcome } from './background-worktree-removal-bridge' export function registerRuntimeClientIpcBridge( unsubs: (() => void)[], @@ -110,6 +111,12 @@ export function registerRuntimeClientIpcBridge( return } if (event.type === 'worktreesChanged') { + if (event.removalOutcome) { + applyBackgroundWorktreeRemovalOutcome( + toRuntimeExecutionHostId(environmentId), + event.removalOutcome + ) + } void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() => worktreeChangeRefreshQueue.enqueue({ repoId: event.repoId, diff --git a/src/renderer/src/store/slices/worktrees-background-removal.test.ts b/src/renderer/src/store/slices/worktrees-background-removal.test.ts new file mode 100644 index 000000000000..19e5c07f47ff --- /dev/null +++ b/src/renderer/src/store/slices/worktrees-background-removal.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { + _resetBackgroundWorktreeRemovalsForTests, + setBackgroundWorktreeRemovalRowsLookup, + settleBackgroundWorktreeRemoval +} from './worktrees/teardown/background-worktree-removal' +import { + _resetBackgroundWorktreeRemovalBridgeForTests, + applyBackgroundWorktreeRemovalOutcome, + reconcileHostWorktreeRemovals +} from '../../hooks/ipc-events/background-worktree-removal-bridge' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn(() => []) +})) + +const mockApi = createStoreCascadesMockApi() +const worktreeId = 'repo1::/path/wt1' +const hostKey = getWorktreeHostIdentity({ id: worktreeId, hostId: 'local' }) + +function seedRow( + store: ReturnType, + overrides: { removing?: true } = {} +): void { + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/path/wt1', + hostId: 'local', + ...overrides + }) + ] + } + }) +} + +function deleteState(store: ReturnType) { + const states = store.getState().deleteStateByWorktreeId + return states[hostKey] ?? states[worktreeId] +} + +describe('removing a worktree the host deletes in the background', () => { + let store: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + clearRuntimeCompatibilityCacheForTests() + store = createTestStore() + setBackgroundWorktreeRemovalRowsLookup((id) => + (store.getState().worktreesByRepo.repo1 ?? []).filter((row) => row.id === id) + ) + mockApi.worktrees.remove.mockResolvedValue({ + removing: true, + catalogVersion: { epoch: 'e', sequence: 1 } + }) + }) + + afterEach(() => { + _resetBackgroundWorktreeRemovalsForTests() + _resetBackgroundWorktreeRemovalBridgeForTests() + }) + + it('keeps the card Deleting until the host reports the delete finished', async () => { + seedRow(store) + const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) + await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) + await Promise.resolve() + + expect(deleteState(store)?.isDeleting).toBe(true) + expect(store.getState().worktreesByRepo.repo1?.map((row) => row.id)).toEqual([worktreeId]) + + settleBackgroundWorktreeRemoval('local', { + worktreeId, + status: 'removed', + preservedBranch: { branchName: 'feature', head: 'abc123' } + }) + + await expect(removal).resolves.toMatchObject({ + ok: true, + preservedBranch: { branchName: 'feature', head: 'abc123' } + }) + expect(store.getState().worktreesByRepo.repo1).toEqual([]) + // The preserved-branch notice reaches the user through the same path an inline delete used. + expect(toast.warning).toHaveBeenCalledTimes(1) + }) + + it('shows the host error on the card when the delete fails after acceptance', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + seedRow(store) + const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) + await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) + await Promise.resolve() + + settleBackgroundWorktreeRemoval('local', { + worktreeId, + status: 'failed', + error: 'Failed to delete worktree at /path/wt1. Permission denied' + }) + + await expect(removal).resolves.toEqual({ + ok: false, + error: 'Failed to delete worktree at /path/wt1. Permission denied' + }) + expect(deleteState(store)).toMatchObject({ + isDeleting: false, + error: 'Failed to delete worktree at /path/wt1. Permission denied' + }) + expect(store.getState().worktreesByRepo.repo1?.map((row) => row.id)).toEqual([worktreeId]) + }) + + it('finishes from the listing when the outcome event never arrives', async () => { + seedRow(store) + const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) + await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) + await Promise.resolve() + + seedRow(store, { removing: true }) + reconcileHostWorktreeRemovals(store) + seedStore(store, { worktreesByRepo: { repo1: [] } }) + reconcileHostWorktreeRemovals(store) + + await expect(removal).resolves.toEqual({ ok: true }) + }) + + it('shows Deleting on a client that did not start the delete, from the host marker alone', () => { + seedRow(store, { removing: true }) + reconcileHostWorktreeRemovals(store) + expect(deleteState(store)).toMatchObject({ isDeleting: true, phase: 'deleting' }) + + // Git finished or the host dropped the marker: the card returns to normal. + seedRow(store) + reconcileHostWorktreeRemovals(store) + expect(deleteState(store)).toBeUndefined() + }) + + it('puts a failure the host publishes on a card it marked Deleting', () => { + seedRow(store, { removing: true }) + reconcileHostWorktreeRemovals(store) + + applyBackgroundWorktreeRemovalOutcome( + 'local', + { worktreeId, status: 'failed', error: 'Permission denied' }, + store + ) + + expect(deleteState(store)).toMatchObject({ isDeleting: false, error: 'Permission denied' }) + }) + + it('leaves a delete this renderer started to that flow', () => { + seedRow(store, { removing: true }) + store.getState().markWorktreesDeleting([{ id: worktreeId, hostId: 'local' }]) + reconcileHostWorktreeRemovals(store) + seedRow(store) + reconcileHostWorktreeRemovals(store) + expect(deleteState(store)?.isDeleting).toBe(true) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts new file mode 100644 index 000000000000..a67ca8d70805 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts @@ -0,0 +1,164 @@ +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types' +import type { WorktreeRemovalOutcome } from '../../../../../../shared/worktree/removal-outcome' +import type { Worktree } from '../../../../../../shared/worktree/types' +import type { RuntimeClientTarget } from '../../../../runtime/runtime-client-target' + +type Waiter = { + worktreeId: string + hostId: ExecutionHostId + accepted: RemoveWorktreeResult + knownToRenderer: boolean + observedRemoving: boolean + resolve: (result: RemoveWorktreeResult) => void + reject: (error: Error) => void +} + +// Why: on a runtime connection the outcome event and the acceptance reply travel separately, so a +// fast failure can land first; keep it long enough for the waiter that is about to register. +const EARLY_OUTCOME_TTL_MS = 60_000 + +type RemovalRow = Pick +type RowsLookup = (worktreeId: string) => readonly RemovalRow[] + +const waiters = new Map() +// Why injected: this slice cannot import the store; the app bridge that watches listings supplies it. +let rowsLookup: RowsLookup | null = null +const earlyOutcomes = new Map() + +function removalKey(hostId: ExecutionHostId, worktreeId: string): string { + return `${hostId}\0${worktreeId}` +} + +/** The host id this renderer files rows from `target` under, which is what outcomes are keyed by. */ +export function backgroundRemovalHostId(target: RuntimeClientTarget): ExecutionHostId { + return target.kind === 'local' + ? LOCAL_EXECUTION_HOST_ID + : toRuntimeExecutionHostId(target.environmentId) +} + +export function rowHostId(row: Pick): ExecutionHostId { + return row.hostId ?? LOCAL_EXECUTION_HOST_ID +} + +export function setBackgroundWorktreeRemovalRowsLookup(lookup: RowsLookup | null): void { + rowsLookup = lookup +} + +function findRow( + rows: readonly RemovalRow[], + hostId: ExecutionHostId, + worktreeId: string +): RemovalRow | undefined { + return rows.find((row) => row.id === worktreeId && rowHostId(row) === hostId) +} + +function finishedResult( + accepted: RemoveWorktreeResult, + outcome: WorktreeRemovalOutcome +): RemoveWorktreeResult { + if (outcome.status === 'failed') { + throw new Error(outcome.error) + } + const { removing: _removing, catalogVersion: _acceptedVersion, ...rest } = accepted + return { + ...rest, + ...(outcome.preservedBranch ? { preservedBranch: outcome.preservedBranch } : {}), + ...(outcome.catalogVersion ? { catalogVersion: outcome.catalogVersion } : {}) + } +} + +/** + * Resolves when the host finishes a removal it accepted in the background, with the result an + * inline removal would have returned, or rejects with the host's error. + */ +export function waitForBackgroundWorktreeRemoval(args: { + hostId: ExecutionHostId + worktreeId: string + accepted: RemoveWorktreeResult +}): Promise { + const key = removalKey(args.hostId, args.worktreeId) + const early = earlyOutcomes.get(key) + earlyOutcomes.delete(key) + if (early && early.expiresAt > Date.now()) { + try { + return Promise.resolve(finishedResult(args.accepted, early.outcome)) + } catch (error) { + return Promise.reject(error) + } + } + return new Promise((resolve, reject) => { + waiters.get(key)?.reject(new Error('Superseded by a newer delete of this workspace.')) + waiters.set(key, { + worktreeId: args.worktreeId, + hostId: args.hostId, + accepted: args.accepted, + // Only a row this renderer listed can prove the delete finished by disappearing. + knownToRenderer: + findRow(rowsLookup?.(args.worktreeId) ?? [], args.hostId, args.worktreeId) !== undefined, + observedRemoving: false, + resolve, + reject + }) + }) +} + +/** Returns false when no removal in this renderer was waiting on the outcome. */ +export function settleBackgroundWorktreeRemoval( + hostId: ExecutionHostId, + outcome: WorktreeRemovalOutcome +): boolean { + const key = removalKey(hostId, outcome.worktreeId) + const waiter = waiters.get(key) + if (!waiter) { + const now = Date.now() + for (const [earlyKey, early] of earlyOutcomes) { + if (early.expiresAt <= now) { + earlyOutcomes.delete(earlyKey) + } + } + earlyOutcomes.set(key, { outcome, expiresAt: now + EARLY_OUTCOME_TTL_MS }) + return false + } + waiters.delete(key) + try { + waiter.resolve(finishedResult(waiter.accepted, outcome)) + } catch (error) { + waiter.reject(error instanceof Error ? error : new Error(String(error))) + } + return true +} + +/** + * Settles waiters from listings when the outcome event never arrives (a dropped runtime + * connection): a row seen removing that then vanishes finished, one that returns unmarked did not. + */ +export function settleBackgroundWorktreeRemovalsFromRows(rowsFor: RowsLookup): void { + for (const [key, waiter] of waiters) { + const row = findRow(rowsFor(waiter.worktreeId), waiter.hostId, waiter.worktreeId) + if (row?.removing) { + waiter.observedRemoving = true + continue + } + if (!row && (waiter.observedRemoving || waiter.knownToRenderer)) { + waiters.delete(key) + const { removing: _removing, catalogVersion: _acceptedVersion, ...rest } = waiter.accepted + waiter.resolve(rest) + continue + } + if (row && waiter.observedRemoving) { + waiters.delete(key) + waiter.reject(new Error('The delete did not finish. Try again.')) + } + } +} + +export function _resetBackgroundWorktreeRemovalsForTests(): void { + waiters.clear() + earlyOutcomes.clear() + rowsLookup = null +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts index 3b2a11da6e4e..37a73e4f55a9 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts @@ -4,6 +4,10 @@ import { callRuntimeRpc, type getActiveRuntimeTarget } from '../../../../runtime import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' import type { RemoveWorktreeOptions } from '../../worktree-removal-options' import { ARCHIVE_HOOK_TIMEOUT_MS } from '../../../../../../shared/worktree/archive-hook-removal-gate' +import { + backgroundRemovalHostId, + waitForBackgroundWorktreeRemoval +} from './background-worktree-removal' /** * Sends the destructive removal over whichever transport owns this workspace. @@ -23,6 +27,20 @@ export async function dispatchWorktreeRemoval(args: { /** Re-checks mid-flight ownership immediately before the destructive call. */ assertCurrent: () => void }): Promise { + const accepted = await requestWorktreeRemoval(args) + // The card stays Deleting while the host's Git finishes; its outcome settles this. + return accepted?.removing + ? waitForBackgroundWorktreeRemoval({ + hostId: backgroundRemovalHostId(args.target), + worktreeId: args.worktreeId, + accepted + }) + : accepted +} + +async function requestWorktreeRemoval( + args: Parameters[0] +): Promise { const { worktreeId, hostId, force, skipArchive, forgetLocalOnly, target, options } = args const snapshotPruneBatch = options?.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.test.ts b/src/renderer/src/web/web-runtime-connection-frame-router.test.ts index 414fc5c40881..de0854bd532b 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.test.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, + WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { routeWebRuntimeConnectionFrame } from './web-runtime-connection-frame-router' describe('web runtime connection capability advertisement', () => { - it('advertises GitHub PR suppression during E2EE authentication', async () => { + it('advertises GitHub PR suppression and the removing marker during E2EE authentication', async () => { const sendEncrypted = vi.fn(() => true) await routeWebRuntimeConnectionFrame(JSON.stringify({ type: 'e2ee_ready' }), undefined, { @@ -24,7 +27,9 @@ describe('web runtime connection capability advertisement', () => { expect.objectContaining({ type: 'e2ee_auth', clientCapabilities: expect.arrayContaining([ - WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY + WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, + // The web client runs the desktop renderer, which shows Deleting from the marker. + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY ]) }) ) diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index e9fa20fa0150..df56f1438920 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -6,6 +6,7 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY @@ -67,7 +68,8 @@ export async function routeWebRuntimeConnectionFrame( AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, - WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY ] }) return From 5f5c3c94da5d225ead5b97d96dd1d8b898df359a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:17:38 -0700 Subject: [PATCH 07/46] test(worktrees): type the removal harness and projection for the node typecheck --- src/main/ipc/worktrees-test-runtime-stub.ts | 2 +- .../worktree-removal-and-reconciliation-part-03.spec.ts | 7 +++++-- src/main/runtime/rpc/worktree-removal-marker-projection.ts | 2 +- src/main/worktree-background-removal.test.ts | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/worktrees-test-runtime-stub.ts b/src/main/ipc/worktrees-test-runtime-stub.ts index 3bfe3a56f26f..53f4deabcdbd 100644 --- a/src/main/ipc/worktrees-test-runtime-stub.ts +++ b/src/main/ipc/worktrees-test-runtime-stub.ts @@ -28,7 +28,7 @@ export type WorktreeRuntimeStub = { /** Why: create-flow tests need a minimal runtime; full fetchRemoteWithCache behavior lives in fetch-remote-cache.test.ts. */ export function createWorktreeRuntimeStub(mainWindow?: { - webContents: { send: (...args: unknown[]) => unknown } + webContents: { send: (channel: string, ...args: unknown[]) => unknown } }): WorktreeRuntimeStub { // Why here: every harness setup builds a stub, so no removal from an earlier test leaks in. _resetPendingWorktreeRemovalsForTests() diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index e2d6b95a3469..949eef098399 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { resetWorktreeTestSshHostHome } from '../../worktree-removal-test-ssh-host-home' import { @@ -283,7 +283,10 @@ describe('OrcaRuntimeService', () => { return {} }) - const resolveTarget = vi.spyOn(runtime as never, 'resolveWorktreeRemovalTarget') + const resolveTarget: MockInstance<(...args: unknown[]) => Promise> = vi.spyOn( + runtime as never, + 'resolveWorktreeRemovalTarget' + ) const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID) await removeStarted.promise diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.ts index a25204bce12b..811ee83d9866 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.ts @@ -18,7 +18,7 @@ function readsWorktreeRemovalMarker(context: Pick(result: T, context: Pick): T { - const worktrees = projectPendingWorktreeRemovals( + const worktrees = projectPendingWorktreeRemovals( result.worktrees, (worktree) => worktree.id, readsWorktreeRemovalMarker(context) diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts index 5132f20255b6..79fbad67e7cf 100644 --- a/src/main/worktree-background-removal.test.ts +++ b/src/main/worktree-background-removal.test.ts @@ -170,7 +170,7 @@ describe('background worktree removal', () => { }) it('returns listings untouched when nothing is being removed', () => { - const rows = [{ id: removal.worktreeId }] + const rows: { id: string; hostId?: undefined }[] = [{ id: removal.worktreeId }] expect(projectPendingWorktreeRemovals(rows, (row) => row.id, false)).toBe(rows) }) }) From fc75c61d2be2f9b3a083eb6b346521193be7f815 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:26:59 -0700 Subject: [PATCH 08/46] fix(worktrees): don't fail a delete retry with an earlier attempt's buffered failure A background removal's outcome that reached this renderer with no waiter (another client's delete, a host-marked card, or one already settled from listings) was buffered for 60 s and consumed by the next delete of the same workspace, so retrying a failed delete failed at once with the old error while the host was deleting. Drop the buffered outcome before sending the request; only an outcome that arrives after it can belong to it. --- .../worktrees-background-removal.test.ts | 21 +++++++++++++++++++ .../teardown/background-worktree-removal.ts | 11 ++++++++++ .../teardown/dispatch-worktree-removal.ts | 6 +++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/store/slices/worktrees-background-removal.test.ts b/src/renderer/src/store/slices/worktrees-background-removal.test.ts index 19e5c07f47ff..f1fae2444d4b 100644 --- a/src/renderer/src/store/slices/worktrees-background-removal.test.ts +++ b/src/renderer/src/store/slices/worktrees-background-removal.test.ts @@ -158,6 +158,27 @@ describe('removing a worktree the host deletes in the background', () => { expect(deleteState(store)).toMatchObject({ isDeleting: false, error: 'Permission denied' }) }) + it('does not fail a retry with the failure of an earlier delete it did not wait on', async () => { + seedRow(store, { removing: true }) + reconcileHostWorktreeRemovals(store) + applyBackgroundWorktreeRemovalOutcome( + 'local', + { worktreeId, status: 'failed', error: 'Permission denied' }, + store + ) + seedRow(store) + reconcileHostWorktreeRemovals(store) + + const retry = store.getState().removeWorktree({ id: worktreeId, executionHostId: 'local' }) + await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) + await Promise.resolve() + await Promise.resolve() + expect(deleteState(store)).toMatchObject({ isDeleting: true, error: null }) + + settleBackgroundWorktreeRemoval('local', { worktreeId, status: 'removed' }) + await expect(retry).resolves.toEqual({ ok: true }) + }) + it('leaves a delete this renderer started to that flow', () => { seedRow(store, { removing: true }) store.getState().markWorktreesDeleting([{ id: worktreeId, hostId: 'local' }]) diff --git a/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts index a67ca8d70805..089b7d617462 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts @@ -72,6 +72,17 @@ function finishedResult( } } +/** + * Drops any outcome buffered for this workspace before a new removal request is sent: it belongs + * to an earlier removal (another client's, or one settled from listings), never to this request. + */ +export function discardEarlierWorktreeRemovalOutcome( + hostId: ExecutionHostId, + worktreeId: string +): void { + earlyOutcomes.delete(removalKey(hostId, worktreeId)) +} + /** * Resolves when the host finishes a removal it accepted in the background, with the result an * inline removal would have returned, or rejects with the host's error. diff --git a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts index 37a73e4f55a9..d6a30e1b17c6 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts @@ -6,6 +6,7 @@ import type { RemoveWorktreeOptions } from '../../worktree-removal-options' import { ARCHIVE_HOOK_TIMEOUT_MS } from '../../../../../../shared/worktree/archive-hook-removal-gate' import { backgroundRemovalHostId, + discardEarlierWorktreeRemovalOutcome, waitForBackgroundWorktreeRemoval } from './background-worktree-removal' @@ -27,11 +28,14 @@ export async function dispatchWorktreeRemoval(args: { /** Re-checks mid-flight ownership immediately before the destructive call. */ assertCurrent: () => void }): Promise { + const removalHostId = backgroundRemovalHostId(args.target) + // Why: a buffered failure from an earlier attempt would otherwise fail this retry on acceptance. + discardEarlierWorktreeRemovalOutcome(removalHostId, args.worktreeId) const accepted = await requestWorktreeRemoval(args) // The card stays Deleting while the host's Git finishes; its outcome settles this. return accepted?.removing ? waitForBackgroundWorktreeRemoval({ - hostId: backgroundRemovalHostId(args.target), + hostId: removalHostId, worktreeId: args.worktreeId, accepted }) From c462abad3ea5f4e634fc347232f8232b8d3e3b34 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:32:31 -0700 Subject: [PATCH 09/46] fix(worktrees): let only a gap in host events settle a background delete from listings Git unlists the checkout before the host deletes the branch, cleans the push target and purges metadata, and the worktree-directory watcher refetches within 250 ms. The renderer read the missing row as a finished delete, so the waiter resolved without the preserved branch (no toast) and a failure in those last steps showed as success; the real outcome was then dropped. The listing fallback exists only for a lost outcome event, so it now applies only after this host's event stream had a gap: a new subscription or a replay after reconnect. --- .../ipc-events/runtime-client-ipc-bridge.ts | 4 ++ .../worktrees-background-removal.test.ts | 46 ++++++++++++++++++- .../teardown/background-worktree-removal.ts | 42 ++++++++++++++--- .../teardown/dispatch-worktree-removal.ts | 6 +-- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index cc3be8894c15..be7e354a87c1 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -29,6 +29,7 @@ import { } from './runtime-environment-subscription-selection' import type { WorktreeEventRuntime } from './worktree-event-runtime' import { applyBackgroundWorktreeRemovalOutcome } from './background-worktree-removal-bridge' +import { noteBackgroundWorktreeRemovalEventGap } from '../../store/slices/worktrees/teardown/background-worktree-removal' export function registerRuntimeClientIpcBridge( unsubs: (() => void)[], @@ -145,6 +146,8 @@ export function registerRuntimeClientIpcBridge( getDesiredEnvironmentIds: () => getRuntimeClientEventEnvironmentIds(useAppStore.getState()), getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]), subscribe: (environmentId, onEvent, onError, isCurrent) => { + // Why: events emitted before this subscription existed were not delivered to it. + noteBackgroundWorktreeRemovalEventGap(toRuntimeExecutionHostId(environmentId)) const sshGeneration = getEnvironmentSshStateGeneration(environmentId) const runtimeGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) const runtimeRevision = getRuntimeEnvironmentRevision(environmentId) @@ -164,6 +167,7 @@ export function registerRuntimeClientIpcBridge( if (!isCurrent()) { return } + noteBackgroundWorktreeRemovalEventGap(toRuntimeExecutionHostId(environmentId)) invalidateRuntimeClientEventReplay({ getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, refreshRuntimeStatus: () => { diff --git a/src/renderer/src/store/slices/worktrees-background-removal.test.ts b/src/renderer/src/store/slices/worktrees-background-removal.test.ts index f1fae2444d4b..f9a4516018da 100644 --- a/src/renderer/src/store/slices/worktrees-background-removal.test.ts +++ b/src/renderer/src/store/slices/worktrees-background-removal.test.ts @@ -5,6 +5,7 @@ import { createStoreCascadesMockApi } from './store-cascades-test-harness' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { _resetBackgroundWorktreeRemovalsForTests, + noteBackgroundWorktreeRemovalEventGap, setBackgroundWorktreeRemovalRowsLookup, settleBackgroundWorktreeRemoval } from './worktrees/teardown/background-worktree-removal' @@ -120,20 +121,63 @@ describe('removing a worktree the host deletes in the background', () => { expect(store.getState().worktreesByRepo.repo1?.map((row) => row.id)).toEqual([worktreeId]) }) - it('finishes from the listing when the outcome event never arrives', async () => { + it('waits for the outcome when Git unlists the row before the host finishes the removal', async () => { seedRow(store) const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) await Promise.resolve() + // The worktree-directory watcher refetches as soon as Git drops the registration, while the + // host is still deleting the branch. seedRow(store, { removing: true }) reconcileHostWorktreeRemovals(store) seedStore(store, { worktreesByRepo: { repo1: [] } }) reconcileHostWorktreeRemovals(store) + expect(deleteState(store)?.isDeleting).toBe(true) + + settleBackgroundWorktreeRemoval('local', { + worktreeId, + status: 'removed', + preservedBranch: { branchName: 'feature', head: 'abc123' } + }) + await expect(removal).resolves.toMatchObject({ + ok: true, + preservedBranch: { branchName: 'feature', head: 'abc123' } + }) + expect(toast.warning).toHaveBeenCalledTimes(1) + }) + + it('finishes from the listing when the event stream had a gap', async () => { + seedRow(store) + const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) + await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) + await Promise.resolve() + + seedRow(store, { removing: true }) + reconcileHostWorktreeRemovals(store) + seedStore(store, { worktreesByRepo: { repo1: [] } }) + reconcileHostWorktreeRemovals(store) + noteBackgroundWorktreeRemovalEventGap('local') await expect(removal).resolves.toEqual({ ok: true }) }) + it('reports a delete the host dropped once a gap shows the row back unmarked', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + seedRow(store) + const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) + await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) + await Promise.resolve() + + noteBackgroundWorktreeRemovalEventGap('local') + seedRow(store, { removing: true }) + reconcileHostWorktreeRemovals(store) + seedRow(store) + reconcileHostWorktreeRemovals(store) + + await expect(removal).resolves.toMatchObject({ ok: false }) + }) + it('shows Deleting on a client that did not start the delete, from the host marker alone', () => { seedRow(store, { removing: true }) reconcileHostWorktreeRemovals(store) diff --git a/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts index 089b7d617462..a7916b79baaf 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts @@ -14,6 +14,8 @@ type Waiter = { accepted: RemoveWorktreeResult knownToRenderer: boolean observedRemoving: boolean + /** Set once this host's event stream had a gap, so the outcome event may never arrive. */ + eventsMayBeLost: boolean resolve: (result: RemoveWorktreeResult) => void reject: (error: Error) => void } @@ -29,6 +31,7 @@ const waiters = new Map() // Why injected: this slice cannot import the store; the app bridge that watches listings supplies it. let rowsLookup: RowsLookup | null = null const earlyOutcomes = new Map() +const eventGapGenerationByHost = new Map() function removalKey(hostId: ExecutionHostId, worktreeId: string): string { return `${hostId}\0${worktreeId}` @@ -73,14 +76,32 @@ function finishedResult( } /** - * Drops any outcome buffered for this workspace before a new removal request is sent: it belongs - * to an earlier removal (another client's, or one settled from listings), never to this request. + * Call just before sending a removal request. Drops any outcome buffered for this workspace (it + * belongs to an earlier removal, never to this request) and returns the host's event-gap + * generation for `waitForBackgroundWorktreeRemoval`. */ -export function discardEarlierWorktreeRemovalOutcome( +export function beginBackgroundWorktreeRemovalRequest( hostId: ExecutionHostId, worktreeId: string -): void { +): number { earlyOutcomes.delete(removalKey(hostId, worktreeId)) + return eventGapGenerationByHost.get(hostId) ?? 0 +} + +/** + * Records that events from this host may have been dropped (a reconnect or a new subscription). + * Only then may listings settle a waiting removal, since its outcome event may never come. + */ +export function noteBackgroundWorktreeRemovalEventGap(hostId: ExecutionHostId): void { + eventGapGenerationByHost.set(hostId, (eventGapGenerationByHost.get(hostId) ?? 0) + 1) + for (const waiter of waiters.values()) { + if (waiter.hostId === hostId) { + waiter.eventsMayBeLost = true + } + } + if (rowsLookup) { + settleBackgroundWorktreeRemovalsFromRows(rowsLookup) + } } /** @@ -91,6 +112,8 @@ export function waitForBackgroundWorktreeRemoval(args: { hostId: ExecutionHostId worktreeId: string accepted: RemoveWorktreeResult + /** From `beginBackgroundWorktreeRemovalRequest`; a gap since then may have dropped the outcome. */ + eventGapGeneration: number }): Promise { const key = removalKey(args.hostId, args.worktreeId) const early = earlyOutcomes.get(key) @@ -112,6 +135,7 @@ export function waitForBackgroundWorktreeRemoval(args: { knownToRenderer: findRow(rowsLookup?.(args.worktreeId) ?? [], args.hostId, args.worktreeId) !== undefined, observedRemoving: false, + eventsMayBeLost: (eventGapGenerationByHost.get(args.hostId) ?? 0) !== args.eventGapGeneration, resolve, reject }) @@ -145,11 +169,16 @@ export function settleBackgroundWorktreeRemoval( } /** - * Settles waiters from listings when the outcome event never arrives (a dropped runtime - * connection): a row seen removing that then vanishes finished, one that returns unmarked did not. + * Settles waiters from listings when the outcome event may have been lost: a row seen removing + * that then vanishes finished, one that returns unmarked did not. */ export function settleBackgroundWorktreeRemovalsFromRows(rowsFor: RowsLookup): void { for (const [key, waiter] of waiters) { + // Why: Git unlists the row before the branch delete and metadata purge finish, so while the + // event stream is intact only the outcome carries the preserved branch or a late failure. + if (!waiter.eventsMayBeLost) { + continue + } const row = findRow(rowsFor(waiter.worktreeId), waiter.hostId, waiter.worktreeId) if (row?.removing) { waiter.observedRemoving = true @@ -171,5 +200,6 @@ export function settleBackgroundWorktreeRemovalsFromRows(rowsFor: RowsLookup): v export function _resetBackgroundWorktreeRemovalsForTests(): void { waiters.clear() earlyOutcomes.clear() + eventGapGenerationByHost.clear() rowsLookup = null } diff --git a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts index d6a30e1b17c6..1224d7687ef9 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts @@ -6,7 +6,7 @@ import type { RemoveWorktreeOptions } from '../../worktree-removal-options' import { ARCHIVE_HOOK_TIMEOUT_MS } from '../../../../../../shared/worktree/archive-hook-removal-gate' import { backgroundRemovalHostId, - discardEarlierWorktreeRemovalOutcome, + beginBackgroundWorktreeRemovalRequest, waitForBackgroundWorktreeRemoval } from './background-worktree-removal' @@ -29,13 +29,13 @@ export async function dispatchWorktreeRemoval(args: { assertCurrent: () => void }): Promise { const removalHostId = backgroundRemovalHostId(args.target) - // Why: a buffered failure from an earlier attempt would otherwise fail this retry on acceptance. - discardEarlierWorktreeRemovalOutcome(removalHostId, args.worktreeId) + const eventGapGeneration = beginBackgroundWorktreeRemovalRequest(removalHostId, args.worktreeId) const accepted = await requestWorktreeRemoval(args) // The card stays Deleting while the host's Git finishes; its outcome settles this. return accepted?.removing ? waitForBackgroundWorktreeRemoval({ hostId: removalHostId, + eventGapGeneration, worktreeId: args.worktreeId, accepted }) From 2992bd8bd9b0f5af0676eb3409aa1106cc235bd9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:38:32 -0700 Subject: [PATCH 10/46] perf(worktrees): let a bulk delete start each same-repo checkout delete once the host accepts the last A bulk delete ran one worktree at a time per repo (#2259, for packed-refs and ref-lock races in branch cleanup). With Git now deleting each checkout for 20-35 s before the request settles, N worktrees in one repo took N times that. The renderer now queues same-repo deletes only until the host accepts each one; a parent still waits for its nested children to finish. The host serializes the branch cleanup step per repo itself, which also covers removals started by different clients. --- .../remove-worktree-branch-cleanup.test.ts | 28 +++++++ src/main/git/worktree-removal.ts | 11 ++- .../sidebar/delete-worktree-flow.test.ts | 9 ++- .../delete-worktree-parallel-flow.test.ts | 77 +++++++++++++++---- .../sidebar/run-worktree-delete-with-toast.ts | 3 +- .../sidebar/worktree-delete-execution.ts | 21 ++++- .../sidebar/worktree-delete-request.ts | 2 + .../store/slices/worktree-removal-options.ts | 2 + .../teardown/dispatch-worktree-removal.ts | 1 + 9 files changed, 134 insertions(+), 20 deletions(-) diff --git a/src/main/git/remove-worktree-branch-cleanup.test.ts b/src/main/git/remove-worktree-branch-cleanup.test.ts index d6e75c1b33e3..680bf9967c17 100644 --- a/src/main/git/remove-worktree-branch-cleanup.test.ts +++ b/src/main/git/remove-worktree-branch-cleanup.test.ts @@ -444,4 +444,32 @@ branch refs/heads/feature/test ) expect(getGitCalls()).not.toContain('git config --remove-section branch.feature/test') }) + + it('runs branch cleanup for one repo one removal at a time, not the checkout deletes', async () => { + let releaseFirstBranchDelete: () => void = () => {} + const firstBranchDelete = new Promise((resolve) => { + releaseFirstBranchDelete = resolve + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.join(' ') === 'branch -d -- feature/a') { + await firstBranchDelete + } + return { stdout: '', stderr: '' } + }) + + const first = removeWorktree('/repo', '/repo-a', false, { + knownRemovedWorktree: { branch: 'refs/heads/feature/a', head: 'aaa', locked: false } + }) + await vi.waitFor(() => expect(getGitCalls()).toContain('git branch -d -- feature/a')) + const second = removeWorktree('/repo', '/repo-b', false, { + knownRemovedWorktree: { branch: 'refs/heads/feature/b', head: 'bbb', locked: false } + }) + await vi.waitFor(() => expect(getGitCalls()).toContain('git worktree remove /repo-b')) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(getGitCalls()).not.toContain('git branch -d -- feature/b') + + releaseFirstBranchDelete() + await Promise.all([first, second]) + expect(getGitCalls()).toContain('git branch -d -- feature/b') + }) }) diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index c1c21682f594..5ce47ce0bd56 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -18,6 +18,9 @@ import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache' import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache' import { runUnderWorktreeDeleteLimit } from './worktree-delete-limit' +import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' + +const branchCleanupQueueByRepo = new Map>() /** * Remove a worktree. @@ -103,8 +106,12 @@ async function performRemoveWorktree( // Why its own span: branch cleanup can reach the network (`fetch --prune`), so a stall here reads as // `git worktree remove` being slow unless it is timed separately. - return withSpan('worktree.remove.branch_delete', () => - deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options) + // Why serialized per repo: concurrent removals in one repo race `packed-refs.lock` and the + // remote-tracking ref locks of `fetch --prune` (#2259); the checkout deletes above need not wait. + return runKeyedSerializedOperation(branchCleanupQueueByRepo, repoPath, () => + withSpan('worktree.remove.branch_delete', () => + deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options) + ) ) } diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index 8f4b92a8cd20..06424efd904b 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -246,7 +246,8 @@ describe('delete worktree flow', () => { { id: 'wt-1', executionHostId: null }, false, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) ) @@ -261,7 +262,8 @@ describe('delete worktree flow', () => { { id: 'wt-2', executionHostId: null }, false, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) expect(toast.info).toHaveBeenCalledWith( @@ -306,7 +308,8 @@ describe('delete worktree flow', () => { expect(mocks.state.openModal).not.toHaveBeenCalled() expect(mocks.state.removeWorktree).toHaveBeenCalledWith( { id: 'wt-1', executionHostId: null }, - false + false, + { onAccepted: expect.any(Function) } ) await vi.waitFor(() => { expect(onDeleted).toHaveBeenCalledWith([{ id: 'wt-1', executionHostId: null }]) diff --git a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts index b358cbee286c..4836e15c86f3 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts @@ -154,7 +154,8 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'wt-1', executionHostId: null }, false, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith( @@ -162,7 +163,8 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'wt-2', executionHostId: null }, false, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) expect(mocks.state.markWorktreesDeleting).toHaveBeenCalledWith(['wt-1', 'wt-2']) @@ -203,7 +205,8 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'child', executionHostId: null }, false, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) @@ -218,11 +221,53 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'parent', executionHostId: null }, false, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) }) + it('starts the next same-repo delete once the host accepts the previous one', async () => { + const first = deferredDeleteResult() + mocks.state.removeWorktree.mockImplementationOnce( + (_target: unknown, _force: unknown, options?: { onAccepted?: () => void }) => { + options?.onAccepted?.() + return first.promise + } + ) + + const deleted = runDeletesForCurrentWorktrees([ + { id: 'wt-1', displayName: 'one', repoId: 'repo-a', path: '/workspaces/one' }, + { id: 'wt-2', displayName: 'two', repoId: 'repo-a', path: '/workspaces/two' } + ]) + + await vi.waitFor(() => expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(2)) + first.resolve({ ok: true }) + await expect(deleted).resolves.toHaveLength(2) + }) + + it('keeps a parent delete waiting for an accepted child to finish', async () => { + const child = deferredDeleteResult() + mocks.state.removeWorktree.mockImplementationOnce( + (_target: unknown, _force: unknown, options?: { onAccepted?: () => void }) => { + options?.onAccepted?.() + return child.promise + } + ) + + const deleted = runDeletesForCurrentWorktrees([ + { id: 'parent', displayName: 'parent', repoId: 'repo-a', path: '/workspaces/parent' }, + { id: 'child', displayName: 'child', repoId: 'repo-a', path: '/workspaces/parent/child' } + ]) + + await Promise.resolve() + await Promise.resolve() + expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1) + child.resolve({ ok: true }) + await expect(deleted).resolves.toHaveLength(2) + expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(2) + }) + it('deletes nested workspaces before their parent within the same repo', async () => { await runDeletesForCurrentWorktrees([ { id: 'parent', displayName: 'parent', repoId: 'repo-a', path: '/workspaces/parent' }, @@ -234,7 +279,8 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'child', executionHostId: null }, false, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith( @@ -242,7 +288,8 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'parent', executionHostId: null }, false, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) }) @@ -261,7 +308,8 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'wt-1', executionHostId: null }, true, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith( @@ -269,7 +317,8 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'wt-2', executionHostId: null }, true, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) }) @@ -293,7 +342,8 @@ describe('runWorktreeDeletesInParallel', () => { expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1) expect(mocks.state.removeWorktree).toHaveBeenCalledWith( { id: 'wt-1', executionHostId: null }, - false + false, + { onAccepted: expect.any(Function) } ) expect(toast.error).not.toHaveBeenCalled() }) @@ -328,13 +378,13 @@ describe('runWorktreeDeletesInParallel', () => { 1, { id: 'shared', executionHostId: 'local' }, false, - { suppressPreservedBranchToast: true } + { suppressPreservedBranchToast: true, onAccepted: expect.any(Function) } ) expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith( 2, { id: 'shared', executionHostId: 'ssh:builder' }, false, - { suppressPreservedBranchToast: true } + { suppressPreservedBranchToast: true, onAccepted: expect.any(Function) } ) }) @@ -363,7 +413,8 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'child', executionHostId: null }, false, { - suppressPreservedBranchToast: true + suppressPreservedBranchToast: true, + onAccepted: expect.any(Function) } ) expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('parent') @@ -400,7 +451,7 @@ describe('runWorktreeDeletesInParallel', () => { expect(mocks.state.removeWorktree).toHaveBeenCalledWith( { id: hostBParent.id, executionHostId: hostBParent.hostId }, false, - { suppressPreservedBranchToast: true } + { suppressPreservedBranchToast: true, onAccepted: expect.any(Function) } ) }) diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts index 82e3a1ed0481..9c39a2328252 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts @@ -36,7 +36,8 @@ export function runWorktreeDeleteWithToast( const removeOptions = { ...(options.suppressPreservedBranchToast ? { suppressPreservedBranchToast: true } : {}), - ...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {}) + ...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {}), + ...(options.onAccepted ? { onAccepted: options.onAccepted } : {}) } const showFailureToast = ( error: string, diff --git a/src/renderer/src/components/sidebar/worktree-delete-execution.ts b/src/renderer/src/components/sidebar/worktree-delete-execution.ts index 5554c73d34da..87a3f40453f1 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-execution.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-execution.ts @@ -108,8 +108,20 @@ export async function runWorktreeDeletesInParallel( Array.from(groups.values()).map(async (group) => { const deletedInGroup: WorktreeRemovalTarget[] = [] const failedInGroup: (typeof group)[number][] = [] + const started: { path: string; settled: Promise }[] = [] for (const target of group) { - await runInWorktreeDeleteTurn(target.id, async () => { + // A descendant's outcome decides whether its ancestor may be deleted at all. + const descendants = started.filter((earlier) => + isStrictDescendantPath(target.path, earlier.path) + ) + if (descendants.length > 0) { + await Promise.all(descendants.map((earlier) => earlier.settled)) + } + let markAccepted: () => void = () => {} + const accepted = new Promise((resolve) => { + markAccepted = resolve + }) + const settled = runInWorktreeDeleteTurn(target.id, async () => { // A queued target may be recreated while an earlier repo sibling is deleting. // Why by host (STA-4343): the id-keyed map keeps ONE row per `repoId::path`, // so on a two-host collision it can hand back the other host's row — whose @@ -134,6 +146,7 @@ export async function runWorktreeDeletesInParallel( { ...options, focusSuccessorOnDelete: false, + onAccepted: markAccepted, suppressPreservedBranchToast: aggregatePreservedBranches, ...(snapshotPruneBatch ? { snapshotPruneBatchId: snapshotPruneBatch.batchId } : {}), onPreservedBranch: (branch) => { @@ -149,7 +162,13 @@ export async function runWorktreeDeletesInParallel( failedInGroup.push(target) } }) + void settled.catch(() => {}) + started.push({ path: target.path, settled }) + // Why: same-repo deletes queue only until the host accepts; it serializes their ref + // cleanup itself, and Git's 20-35 s checkout delete must not run one repo sibling at a time. + await Promise.race([accepted, settled]) } + await Promise.all(started.map((entry) => entry.settled)) return deletedInGroup }) ) diff --git a/src/renderer/src/components/sidebar/worktree-delete-request.ts b/src/renderer/src/components/sidebar/worktree-delete-request.ts index b13b9c072c19..5ce69a91052e 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-request.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-request.ts @@ -28,6 +28,8 @@ export type WorktreeDeleteWithToastOptions = { snapshotPruneBatchId?: string // Batch deletion commits one focus handoff after all targets settle. focusSuccessorOnDelete?: boolean + /** See `RemoveWorktreeOptions.onAccepted`. */ + onAccepted?: () => void } export function toWorktreeDeleteIdentities( diff --git a/src/renderer/src/store/slices/worktree-removal-options.ts b/src/renderer/src/store/slices/worktree-removal-options.ts index c77568248dec..04779f64e2c6 100644 --- a/src/renderer/src/store/slices/worktree-removal-options.ts +++ b/src/renderer/src/store/slices/worktree-removal-options.ts @@ -17,4 +17,6 @@ export type RemoveWorktreeOptions = { sameIdSurvivingHostId?: ExecutionHostId /** Both scan owners are in this cleanup batch, so neither is a survivor. */ ignoreWorkspaceCleanupScanSurvivors?: boolean + /** Fires when the host has accepted the delete; its checkout may still be deleting. */ + onAccepted?: () => void } diff --git a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts index 1224d7687ef9..203900cab5d2 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts @@ -31,6 +31,7 @@ export async function dispatchWorktreeRemoval(args: { const removalHostId = backgroundRemovalHostId(args.target) const eventGapGeneration = beginBackgroundWorktreeRemovalRequest(removalHostId, args.worktreeId) const accepted = await requestWorktreeRemoval(args) + args.options?.onAccepted?.() // The card stays Deleting while the host's Git finishes; its outcome settles this. return accepted?.removing ? waitForBackgroundWorktreeRemoval({ From 0c6765d8f7fa113a26e7256408fbd77b80e37164 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:58:24 -0700 Subject: [PATCH 11/46] test(worktrees): pin the host platform in the mocked removal suites so they pass on Windows Removal now passes -c core.longpaths=true on Windows, so the exact-argv assertions and command-keyed mocks never matched there (17 failures on a Windows host). Pin darwin as the add-worktree suites already do, and drive the one Windows-specific case through the same spy. --- .../remove-worktree-branch-cleanup.test.ts | 10 ++++++- src/main/git/remove-worktree.test.ts | 28 +++++++++++-------- .../worktree-remove-branch-deletion.test.ts | 10 ++++++- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/main/git/remove-worktree-branch-cleanup.test.ts b/src/main/git/remove-worktree-branch-cleanup.test.ts index 680bf9967c17..6c31b5f393b9 100644 --- a/src/main/git/remove-worktree-branch-cleanup.test.ts +++ b/src/main/git/remove-worktree-branch-cleanup.test.ts @@ -1,5 +1,5 @@ import type * as FsPromises from 'node:fs/promises' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' const { gitExecFileAsyncMock, @@ -46,8 +46,16 @@ import { forceDeleteLocalBranch, removeWorktree } from './worktree' const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) +// Why: removal argv carries core.longpaths on Windows; pin a non-Windows default for exact argv. +let platformSpy: MockInstance<() => NodeJS.Platform> + beforeEach(() => { resetWorktreeRemovalState() + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') +}) + +afterEach(() => { + platformSpy.mockRestore() }) describe('removeWorktree', () => { diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index abe94a34711c..e0364b35975f 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -3,7 +3,7 @@ import type * as FsPromises from 'node:fs/promises' import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' const { gitExecFileAsyncMock, @@ -54,8 +54,17 @@ import { const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) +// Why: removal argv carries core.longpaths on Windows, so pin a non-Windows default or the +// exact-argv assertions below fail for a maintainer running vitest on Windows. +let platformSpy: MockInstance<() => NodeJS.Platform> + beforeEach(() => { resetWorktreeRemovalState() + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') +}) + +afterEach(() => { + platformSpy.mockRestore() }) describe('removeWorktree', () => { @@ -355,20 +364,15 @@ branch refs/heads/main }) it('removes a WSL checkout configured for a native Windows repo with Git alone', async () => { - const originalPlatform = process.platform const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\feature' - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - mockGitCommands({}) + platformSpy.mockReturnValue('win32') + mockGitCommands({}) - await removeWorktree('C:\\repo', worktreePath, false, { - knownRemovedWorktree: { branch: '', head: '', locked: false } - }) + await removeWorktree('C:\\repo', worktreePath, false, { + knownRemovedWorktree: { branch: '', head: '', locked: false } + }) - expect(getGitCalls()).toEqual([`git -c core.longpaths=true worktree remove ${worktreePath}`]) - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } + expect(getGitCalls()).toEqual([`git -c core.longpaths=true worktree remove ${worktreePath}`]) }) it('passes one --force before the worktree path for dirty-file removal', async () => { diff --git a/src/main/git/worktree-remove-branch-deletion.test.ts b/src/main/git/worktree-remove-branch-deletion.test.ts index b8535ff2ebeb..6469d675e9f0 100644 --- a/src/main/git/worktree-remove-branch-deletion.test.ts +++ b/src/main/git/worktree-remove-branch-deletion.test.ts @@ -1,5 +1,5 @@ // removeWorktree: branch deletion safety after the checkout is removed. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( () => ({ @@ -24,10 +24,18 @@ describe('removeWorktree', () => { const beforeRemoval = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n' + // Why: removal argv carries core.longpaths on Windows; pin a non-Windows default for exact argv. + let platformSpy: MockInstance<() => NodeJS.Platform> + beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() translateWslOutputPathsMock.mockClear() + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + }) + + afterEach(() => { + platformSpy.mockRestore() }) it('uses safe `branch -d` and preserves a branch with unmerged commits', async () => { From aeb5c9441eafdcbd23eb6ab998ad0b90d5c05339 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:03:42 -0700 Subject: [PATCH 12/46] test(worktrees): type the blocked git remove result instead of a broad object The anti-slop static-analysis gate rejects `object` parameters. --- src/main/ipc/worktrees-background-removal.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index 123572699025..90261b5cc867 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -8,6 +8,7 @@ import { import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { mockKnownFeatureWorktree } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests @@ -104,8 +105,11 @@ function rawRemoveHandler(): RawHandler { return call?.[1] as RawHandler } -function blockGitRemove(): { release: (result?: object) => void; fail: (error: Error) => void } { - let release!: (result?: object) => void +function blockGitRemove(): { + release: (result?: RemoveWorktreeResult) => void + fail: (error: Error) => void +} { + let release!: (result?: RemoveWorktreeResult) => void let fail!: (error: Error) => void removeWorktreeMock.mockImplementation( () => From e72f56c1434f9de6aed48ee052718fd7bd608b33 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:11:24 -0700 Subject: [PATCH 13/46] test(worktrees): clear the changed-code quality gate in the removal suites Merge the duplicate node:fs import, build the mock child without a cast, read worktrees:list rows through one typed helper, and give the remaining casts a SAFETY line. --- .../git-exec-admission-exempt.test.ts | 13 ++++++------ .../worktree-removal-large-tree.bench.test.ts | 3 +-- .../ipc/worktrees-background-removal.test.ts | 21 +++++++++---------- .../orca-runtime-test-lifecycle.spec.ts | 1 + ...removal-and-reconciliation-part-03.spec.ts | 1 + 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/main/git/command-runner/git-exec-admission-exempt.test.ts b/src/main/git/command-runner/git-exec-admission-exempt.test.ts index f838bdfeea7d..5bbd04cf4479 100644 --- a/src/main/git/command-runner/git-exec-admission-exempt.test.ts +++ b/src/main/git/command-runner/git-exec-admission-exempt.test.ts @@ -17,12 +17,13 @@ import { } from './git-subprocess-admission' function mockChild(pid: number): ChildProcess { - const child = new EventEmitter() as EventEmitter & Record - child.pid = pid - child.kill = vi.fn(() => true) - child.stdin = Object.assign(new EventEmitter(), { end: vi.fn() }) - child.stdout = new EventEmitter() - child.stderr = new EventEmitter() + const child = Object.assign(new EventEmitter(), { + pid, + kill: vi.fn(() => true), + stdin: Object.assign(new EventEmitter(), { end: vi.fn() }), + stdout: new EventEmitter(), + stderr: new EventEmitter() + }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exec wrapper only reads pid, kill, stdio and events. return child as unknown as ChildProcess } diff --git a/src/main/git/worktree-removal-large-tree.bench.test.ts b/src/main/git/worktree-removal-large-tree.bench.test.ts index bfdc9df7e03d..02147fdda4a0 100644 --- a/src/main/git/worktree-removal-large-tree.bench.test.ts +++ b/src/main/git/worktree-removal-large-tree.bench.test.ts @@ -2,9 +2,8 @@ // meanwhile. Opt in (it builds ~100k files): ORCA_WORKTREE_REMOVAL_BENCH=1 ./node_modules/.bin/vitest \ // run --config config/vitest.config.ts src/main/git/worktree-removal-large-tree.bench.test.ts import { execFile } from 'node:child_process' -import { appendFileSync, statSync } from 'node:fs' +import { appendFileSync, existsSync, statSync } from 'node:fs' import { link, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { performance } from 'node:perf_hooks' diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index 90261b5cc867..d9b1169691f2 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -105,6 +105,13 @@ function rawRemoveHandler(): RawHandler { return call?.[1] as RawHandler } +type ListedRow = { id: string; removing?: true } + +async function listRepoRows(): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktrees:list resolves the repo's Worktree rows. + return (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as ListedRow[] +} + function blockGitRemove(): { release: (result?: RemoveWorktreeResult) => void fail: (error: Error) => void @@ -147,10 +154,7 @@ describe('worktrees:remove in the background', () => { await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) expect(store.removeWorktreeMeta).not.toHaveBeenCalled() - const during = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { - id: string - removing?: true - }[] + const during = await listRepoRows() expect(during.find((row) => row.id === featureId)?.removing).toBe(true) expect(during.find((row) => row.id === 'repo-1::/workspace/repo')?.removing).toBeUndefined() @@ -163,9 +167,7 @@ describe('worktrees:remove in the background', () => { status: 'removed', preservedBranch: { branchName: 'feature', head: 'feature' } }) - const after = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { - id: string - }[] + const after = await listRepoRows() expect(after.map((row) => row.id)).not.toContain(featureId) }) @@ -184,10 +186,7 @@ describe('worktrees:remove in the background', () => { error: expect.stringContaining('permission denied') }) expect(store.removeWorktreeMeta).not.toHaveBeenCalled() - const rows = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as { - id: string - removing?: true - }[] + const rows = await listRepoRows() expect(rows.find((row) => row.id === featureId)).toBeDefined() expect(rows.find((row) => row.id === featureId)?.removing).toBeUndefined() diff --git a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts index 4da17fe27a4e..a4bb0d16c222 100644 --- a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts +++ b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts @@ -59,6 +59,7 @@ const { sshProviderGenerations, unregisterSshGitProviderMock, updateGitHubIssueM const { updateGitHubPRDetailsMock, updateGitHubPRStateMock, updateGitHubPRTitleMock } = mocks const { updateGitLabIssueMock, updateGitLabMRMock, updateGitLabMRReviewersMock, vi } = mocks +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prototype carries both methods the removal wrapper replaces. awaitBackgroundRemovalsInRuntimeTests(mocks.OrcaRuntimeService.prototype as never) function resetRuntimeTestMocks(): void { diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index 949eef098399..b9d14675427d 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -284,6 +284,7 @@ describe('OrcaRuntimeService', () => { }) const resolveTarget: MockInstance<(...args: unknown[]) => Promise> = vi.spyOn( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveWorktreeRemovalTarget is a protected runtime method the spy only observes. runtime as never, 'resolveWorktreeRemovalTarget' ) From 5e14dc2ffb253988a90ed9c5ae938b2cc2f6376c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:10:24 -0700 Subject: [PATCH 14/46] fix(worktrees): record each background delete durably and finish it after a quit or crash A quit mid-delete left git to finish the checkout on its own while the branch delete and metadata purge never ran; a crash left a normal-looking row. Each accepted local removal now writes a record beside the profile state before git starts, clears it on success or failure, and the host runs the same delete again for any record left at startup, re-deriving what remains from git and disk. An orderly quit stops the checkout delete without waiting for it. --- src/main/git/remove-worktree.test.ts | 32 +++ .../git/worktree-add-pending-removal.test.ts | 5 +- src/main/git/worktree-operation-options.ts | 2 + src/main/git/worktree-removal.ts | 53 +++++ ...ktop-renderer-runtime-capabilities.test.ts | 6 +- .../remove-registered-local-worktree.ts | 22 +- .../orca-runtime-remove-managed-worktree.ts | 11 +- ...runtime-resolve-worktree-removal-target.ts | 39 ++++ ...worktree-removal-marker-projection.test.ts | 5 +- ...interrupted-local-worktree-removal.test.ts | 202 ++++++++++++++++++ ...time-interrupted-local-worktree-removal.ts | 149 +++++++++++++ ...ntime-registered-local-worktree-removal.ts | 42 ++-- src/main/startup/main-process-quit.ts | 3 + .../startup/main-process-ready-runtime.ts | 10 +- ...orktree-background-removal-records.test.ts | 199 +++++++++++++++++ src/main/worktree-background-removal.test.ts | 5 +- src/main/worktree-background-removal.ts | 161 ++++++++++---- src/main/worktree-removal-records.ts | 93 ++++++++ .../src/hooks/useIpcEvents-lifecycle.test.ts | 8 +- .../src/web/web-runtime-client.test.ts | 4 +- 20 files changed, 970 insertions(+), 81 deletions(-) create mode 100644 src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts create mode 100644 src/main/runtime/runtime-interrupted-local-worktree-removal.ts create mode 100644 src/main/worktree-background-removal-records.test.ts create mode 100644 src/main/worktree-removal-records.ts diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index e0364b35975f..d2246da56ad0 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -259,6 +259,38 @@ branch refs/heads/main expect(getGitCalls()).toContain('git branch -d -- feature/test') }) + it('lets a quit stop only the checkout delete, not the branch cleanup that holds ref locks', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + } + }) + const stop = new AbortController() + + await removeWorktree('/repo', '/repo-feature', false, { + checkoutDeleteSignal: stop.signal + }) + + const optionsOf = (command: string): { signal?: AbortSignal } | undefined => + gitExecFileAsyncMock.mock.calls.find((call) => call[0].join(' ') === command)?.[1] + expect(optionsOf('worktree remove /repo-feature')?.signal).toBe(stop.signal) + expect(optionsOf('branch -d -- feature/test')).toBeDefined() + expect(optionsOf('branch -d -- feature/test')?.signal).toBeUndefined() + }) + it('never lets Git run an inherited yes/no prompt program during the delete', async () => { vi.stubEnv('GIT_ASK_YESNO', '/usr/local/bin/prompt') try { diff --git a/src/main/git/worktree-add-pending-removal.test.ts b/src/main/git/worktree-add-pending-removal.test.ts index 70ab5c2bee39..08950c777d4e 100644 --- a/src/main/git/worktree-add-pending-removal.test.ts +++ b/src/main/git/worktree-add-pending-removal.test.ts @@ -29,8 +29,9 @@ function startDeleting(): void { worktreeId: 'repo-1::/repo-feature', repoId: 'repo-1', repoPath: '/repo', - worktreePath: '/repo-feature', - branch: 'feature/test' + worktree: { path: '/repo-feature', branch: 'refs/heads/feature/test', head: 'abc' }, + deleteBranch: true, + force: false }, run: () => new Promise(() => {}), catalogVersion: () => ({ epoch: 'e', sequence: 1 }), diff --git a/src/main/git/worktree-operation-options.ts b/src/main/git/worktree-operation-options.ts index 4bf3d9e94514..82c37a1a679d 100644 --- a/src/main/git/worktree-operation-options.ts +++ b/src/main/git/worktree-operation-options.ts @@ -42,6 +42,8 @@ export type RemoveWorktreeOptions = GitWorktreeExecOptions & { deleteBranch?: boolean forceBranchDelete?: boolean knownRemovedWorktree?: Pick + /** Stops only `git worktree remove`; a killed branch cleanup could strand a ref lock. */ + checkoutDeleteSignal?: AbortSignal } // Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); ample for an ordinary large checkout, but not one behind a slow content filter (#12696). diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 5ce47ce0bd56..39d2056f708e 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -72,6 +72,7 @@ async function performRemoveWorktree( const longPathArgs = windowsLongPathGitArgs(repoPath) const execOptions = { ...gitExecOptions(repoPath, options), + ...(options.checkoutDeleteSignal ? { signal: options.checkoutDeleteSignal } : {}), ...removalGitEnv(), admissionExempt: true as const } @@ -104,6 +105,15 @@ async function performRemoveWorktree( return {} } + return deleteBranchOfRemovedWorktree(repoPath, branchName, branchHead, options) +} + +function deleteBranchOfRemovedWorktree( + repoPath: string, + branchName: string, + branchHead: string, + options: RemoveWorktreeOptions +): Promise { // Why its own span: branch cleanup can reach the network (`fetch --prune`), so a stall here reads as // `git worktree remove` being slow unless it is timed separately. // Why serialized per repo: concurrent removals in one repo race `packed-refs.lock` and the @@ -115,6 +125,49 @@ async function performRemoveWorktree( ) } +/** + * Finishes a removal whose checkout Git no longer registers (it finished deleting, or an earlier + * run did): leftover files, stale admin records, then the branch. Already-gone parts are done. + */ +export async function finishUnregisteredWorktreeRemoval( + repoPath: string, + worktreePath: string, + branch: { name: string; head: string } | null, + options: RemoveWorktreeOptions = {} +): Promise { + try { + await runUnderWorktreeDeleteLimit(() => removeCheckoutLeftByGit(worktreePath, options)) + await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options)).catch( + (error: unknown) => console.warn(`[git] worktree prune failed in ${repoPath}`, error) + ) + if (!branch?.name || !(await localBranchExists(repoPath, branch.name, options))) { + return {} + } + return await withRepoRefMaintenancePaused('worktree-remove', () => + deleteBranchOfRemovedWorktree(repoPath, branch.name, branch.head, options) + ) + } finally { + invalidateSparseCheckoutState(repoPath, worktreePath) + bumpWorktreeScanGeneration(repoPath) + } +} + +async function localBranchExists( + repoPath: string, + branchName: string, + options: RemoveWorktreeOptions +): Promise { + try { + await gitExecFileAsync( + ['show-ref', '--verify', '--quiet', '--', `refs/heads/${branchName}`], + gitExecOptions(repoPath, options) + ) + return true + } catch { + return false + } +} + // Why: Git for Windows runs $GIT_ASK_YESNO when a file stays locked mid-delete; no prompt program may run here. function removalGitEnv(): { env?: NodeJS.ProcessEnv } { const inherited = Object.keys(process.env).filter((key) => key.toUpperCase() === 'GIT_ASK_YESNO') diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 68f6e97e6323..267dff981671 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -21,6 +21,7 @@ import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, @@ -47,7 +48,10 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Opts into a delta feed in place of the full tab list — a remote-transport concern. - SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + // Main marks `removing` on its own worktree IPC listings unconditionally; the renderer never + // lists its own host's worktrees over runtime RPC, where this capability decides mark vs omit. + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY ] /** Gates the renderer must pass against its own main process. The Electron remote list omits all diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index 057756622daa..a2c244b511c1 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -40,7 +40,6 @@ import { stopPtysForDestructiveWorktreeRemoval } from './worktree-removal-ownership' import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' -import { normalizeLocalBranchRef } from '../../../git/worktree-operation-options' import { getLocalWorktreeCatalogVersion } from '../../../local-worktree-scan-generation' import { removesInBackground, @@ -130,7 +129,7 @@ export async function removeRegisteredLocalWorktree( } } - const finish = (): Promise => + const finish = (checkoutDeleteSignal?: AbortSignal): Promise => finishLocalWorktreeRemoval({ context, args, @@ -143,7 +142,8 @@ export async function removeRegisteredLocalWorktree( hasLocalWorktreeGitOptions, deleteBranch, refreshedRegisteredWorktree, - removalGate + removalGate, + checkoutDeleteSignal }) if (!removesInBackground(canonicalWorktreePath, localWorktreeGitOptions)) { const result = await finish() @@ -157,11 +157,12 @@ export async function removeRegisteredLocalWorktree( worktreeId: args.worktreeId, repoId, repoPath: repo.path, - worktreePath: canonicalWorktreePath, - branch: normalizeLocalBranchRef(refreshedRegisteredWorktree.branch) + worktree: refreshedRegisteredWorktree, + deleteBranch, + force: args.force ?? false }, - run: async () => { - const result = await finish() + run: async (stopSignal) => { + const result = await finish(stopSignal) context.options?.onWorktreeLifecycle?.({ kind: 'removed', worktreeId: args.worktreeId, @@ -187,7 +188,8 @@ async function finishLocalWorktreeRemoval({ hasLocalWorktreeGitOptions, deleteBranch, refreshedRegisteredWorktree, - removalGate + removalGate, + checkoutDeleteSignal }: { context: WorktreeIpcContext args: RemoveWorktreeArgs @@ -201,6 +203,7 @@ async function finishLocalWorktreeRemoval({ deleteBranch: boolean refreshedRegisteredWorktree: GitWorktreeInfo removalGate: { finish: (removed: boolean) => Promise } + checkoutDeleteSignal?: AbortSignal }): Promise { const { store, runtime } = context let removalResult: RemoveWorktreeResult | undefined @@ -211,7 +214,8 @@ async function finishLocalWorktreeRemoval({ ...(!deleteBranch ? { deleteBranch } : {}), // Why: reuse the authoritative worktree list already computed here instead of rescanning siblings on the hot delete path. knownRemovedWorktree: refreshedRegisteredWorktree, - ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) + ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}), + checkoutDeleteSignal } removalResult = preserveBranchHeadFallback( await withWorktreeRemoveStageSpan('git_remove', 'local', async () => diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index 2b0658dad996..c3035f5511b9 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -13,7 +13,6 @@ import { type RemoveManagedWorktreeOptions } from './runtime-worktree-selection' import { withWorktreeSpan } from '../observability/instrumentation' -import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { resolveWorktreeRemovalHome, resolveWorktreeRemovalRoute @@ -93,14 +92,8 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM } const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined const removalHostId = repo ? (cleanupHostId ?? getRepoExecutionHostId(repo)) : cleanupHostId - // Host state every removal path drops once Git has let go of the checkout. - const purgeRemovedWorktree = (): void => { - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - } + const purgeRemovedWorktree = (): void => + this.purgeRemovedWorktree(store, removalTarget.id, removalTarget.repoId, removalHostId) const orphanOrFolderResult = await removeOrphanOrFolderWorktree({ runtime: this, store, diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index eeac6dd9a78f..d9ce35224fd2 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -22,6 +22,10 @@ import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' +import { resumeInterruptedWorktreeRemovals } from '../worktree-background-removal' +import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { protected async resolveWorktreeRemovalTarget( @@ -38,6 +42,41 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith }) } + /** Runs the same delete again for each local removal a quit or crash interrupted. */ + finishInterruptedWorktreeRemovals(): void { + const store = this.store + if (!store) { + return + } + resumeInterruptedWorktreeRemovals((record) => + interruptedLocalWorktreeRemovalJob(record, { + store, + acquireWatcherRemoval: this.acquireFileWatcherRemoval, + closeWatchers: (path) => this.closeFileWatchersForRemoval(path), + preservedBranchCleanup: this.preservedBranchCleanup, + purge: ({ worktreeId, repoId }) => + this.purgeRemovedWorktree(store, worktreeId, repoId, LOCAL_EXECUTION_HOST_ID), + onRemoved: ({ worktreeId, worktreePath }) => + this.emitWorktreeLifecycle({ kind: 'removed', worktreeId, path: worktreePath }), + publish: (repoId, outcome) => this.publishWorktreeRemovalChange(repoId, outcome) + }) + ) + } + + // Host state every removal path drops once Git has let go of the checkout. + protected purgeRemovedWorktree( + store: RuntimeStore, + worktreeId: string, + repoId: string, + removalHostId?: ExecutionHostId + ): void { + this.clearOptimisticReconcileToken(worktreeId) + this.removeWorktreeMetadataAndHistory(store, worktreeId, removalHostId) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repoId) + invalidateAuthorizedRootsCache() + } + protected removeWorktreeMetadataAndHistory( store: RuntimeStore, worktreeId: string, diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts index 625f16331199..897bd326e11b 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts @@ -52,8 +52,9 @@ describe('worktree listings while a checkout is being deleted', () => { worktreeId: removingId, repoId: 'repo-1', repoPath: '/work/repo', - worktreePath: '/work/feature', - branch: 'feature' + worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, + deleteBranch: true, + force: false }, run: () => new Promise(() => {}), catalogVersion: () => ({ epoch: 'e', sequence: 1 }), diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts new file mode 100644 index 000000000000..141c5fa25cb7 --- /dev/null +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -0,0 +1,202 @@ +// Real-binary coverage for finishing a removal a quit or crash interrupted: what is left has to +// come from Git and disk, whatever point the earlier run reached. +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, realpath, rm, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' +import type { Store } from '../persistence' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests, + isWorktreeRemovalPending, + loadWorktreeRemovalRecords, + resumeInterruptedWorktreeRemovals +} from '../worktree-background-removal' +import { + readWorktreeRemovalRecords, + writeWorktreeRemovalRecords, + type WorktreeRemovalRecord +} from '../worktree-removal-records' +import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' + +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: () => ({}) +})) + +const execFileAsync = promisify(execFile) + +let scratchDir = '' +let recordsDir = '' +let repoPath = '' +let worktreePath = '' +let repo: Repo + +async function git(args: string[], cwd = repoPath): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/.... + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-interrupted-removal-'))) + recordsDir = join(scratchDir, 'profile') + repoPath = join(scratchDir, 'repo') + worktreePath = join(scratchDir, 'workspaces', 'feature') + await mkdir(recordsDir, { recursive: true }) + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q']) + await git(['config', 'user.email', 'removal@example.invalid']) + await git(['config', 'user.name', 'Worktree Removal']) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A']) + await git(['commit', '-qm', 'seed']) + await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature']) + repo = { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '', addedAt: 0 } +}) + +afterEach(async () => { + _resetPendingWorktreeRemovalsForTests() + await rm(scratchDir, { recursive: true, force: true }) +}) + +async function finishAfterRestart(options: { repoGone?: boolean; head?: string } = {}): Promise<{ + outcome: WorktreeRemovalOutcome | undefined + purged: string[] + remember: ReturnType +}> { + const record: WorktreeRemovalRecord = { + worktreeId: `repo-1::${worktreePath}`, + repoId: 'repo-1', + repoPath, + worktreePath, + branch: 'feature', + head: options.head ?? (await git(['rev-parse', 'feature'])).trim(), + deleteBranch: true, + force: false, + requestedAt: 1 + } + await writeWorktreeRemovalRecords(recordsDir, () => [record]) + await loadWorktreeRemovalRecords(recordsDir) + expect(isWorktreeRemovalPending(record.worktreeId)).toBe(true) + + const storeStub = { + getRepo: (id: string) => (id === repo.id && !options.repoGone ? repo : undefined), + getRepos: () => (options.repoGone ? [] : [repo]), + getWorktreeMeta: () => undefined + } + const outcomes: WorktreeRemovalOutcome[] = [] + const purged: string[] = [] + const remember = vi.fn() + resumeInterruptedWorktreeRemovals((interrupted) => + interruptedLocalWorktreeRemovalJob(interrupted, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the finish reads only repos and worktree metadata from the store here; git options and push-target cleanup are stubbed or short-circuit without a push target. + store: storeStub as unknown as Store, + acquireWatcherRemoval: async () => ({ finish: async () => {} }), + closeWatchers: async () => {}, + preservedBranchCleanup: { + preserveHead: (result) => result ?? {}, + remember + }, + purge: ({ worktreeId }) => purged.push(worktreeId), + onRemoved: () => {}, + publish: (_repoId, outcome) => { + if (outcome) { + outcomes.push(outcome) + } + } + }) + ) + await _settlePendingWorktreeRemovalsForTests() + expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + expect(isWorktreeRemovalPending(record.worktreeId)).toBe(false) + return { outcome: outcomes.at(-1), purged, remember } +} + +describe('finishing an interrupted worktree removal after a restart', () => { + it('finishes a checkout Git was still deleting, branch and metadata included', async () => { + // Git stopped partway: part of the checkout is gone, which reads as local changes. + await unlink(join(worktreePath, 'seed.txt')) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(outcome?.preservedBranch).toBeUndefined() + expect(existsSync(worktreePath)).toBe(false) + expect(await git(['worktree', 'list'])).not.toContain(worktreePath) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([`repo-1::${worktreePath}`]) + }) + + it('deletes the branch when Git finished the checkout but the quit came before the branch', async () => { + await git(['worktree', 'remove', worktreePath]) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([`repo-1::${worktreePath}`]) + }) + + it('keeps an unmerged branch, as a normal removal does', async () => { + await writeFile(join(worktreePath, 'work.txt'), 'work\n') + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'work'], worktreePath) + const head = (await git(['rev-parse', 'feature'])).trim() + await git(['worktree', 'remove', worktreePath]) + + const { outcome, remember } = await finishAfterRestart() + + expect(outcome).toMatchObject({ + status: 'removed', + preservedBranch: { branchName: 'feature', head } + }) + expect((await git(['rev-parse', 'feature'])).trim()).toBe(head) + expect(remember).toHaveBeenCalledWith( + `repo-1::${worktreePath}`, + undefined, + { preservedBranch: { branchName: 'feature', head } }, + head, + undefined + ) + }) + + it('treats a removal that fully finished as done', async () => { + const head = (await git(['rev-parse', 'feature'])).trim() + await git(['worktree', 'remove', worktreePath]) + await git(['branch', '-d', 'feature']) + + const { outcome, purged } = await finishAfterRestart({ head }) + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(outcome?.preservedBranch).toBeUndefined() + expect(purged).toEqual([`repo-1::${worktreePath}`]) + }) + + it('returns the row live when the finish fails, instead of retrying unseen', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + // Another Git client locked it while Orca was not running. + await git(['worktree', 'lock', worktreePath]) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(worktreePath)).toBe(true) + expect(await git(['worktree', 'list'])).toContain(worktreePath) + expect(purged).toEqual([]) + }) + + it('drops a record whose repo Orca no longer has', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { outcome, purged } = await finishAfterRestart({ repoGone: true }) + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(purged).toEqual([]) + expect(existsSync(worktreePath)).toBe(true) + }) +}) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts new file mode 100644 index 000000000000..0ad1d35b3417 --- /dev/null +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -0,0 +1,149 @@ +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { Store } from '../persistence' +import { getLocalWorktreeCatalogVersion } from '../local-worktree-scan-generation' +import type { BackgroundWorktreeRemovalJob } from '../worktree-background-removal' +import { resolveWorktreeRemovalMetadata } from '../worktree-removal-repo-owner' +import type { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' +import { listWorktreesStrict } from '../git/worktree' +import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' +import { areWorktreePathsEqual } from '../git/worktree-path-comparison' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' +import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' +import type { WorktreeRemovalRecord } from '../worktree-removal-records' +import { + cleanupRemovedWorktreePushTarget, + finishRuntimeLocalWorktreeRemoval, + type RuntimeLocalWorktreeRemovalFinishArgs +} from './runtime-registered-local-worktree-removal' + +type InterruptedWorktreeRemovalHost = { + store: Store + acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise }> + closeWatchers: (path: string) => Promise + preservedBranchCleanup: Pick + /** Drops the worktree's host state (metadata, history, caches), as every removal path does. */ + purge: (record: WorktreeRemovalRecord) => void + onRemoved: (record: WorktreeRemovalRecord) => void + publish: (repoId: string, outcome?: WorktreeRemovalOutcome) => void +} + +/** The background job that finishes one interrupted removal on this host. */ +export function interruptedLocalWorktreeRemovalJob( + record: WorktreeRemovalRecord, + host: InterruptedWorktreeRemovalHost +): BackgroundWorktreeRemovalJob { + return { + run: async (stopSignal) => { + const removedPushTarget = resolveWorktreeRemovalMetadata( + host.store, + record.repoId, + record.worktreeId, + LOCAL_EXECUTION_HOST_ID + )?.pushTarget + const result = await finishInterruptedLocalWorktreeRemoval({ + record, + store: host.store, + stopSignal, + removedPushTarget, + acquireWatcherRemoval: host.acquireWatcherRemoval, + closeWatchers: host.closeWatchers, + preserveBranchHead: (result, fallbackHead) => + host.preservedBranchCleanup.preserveHead(result, fallbackHead), + // remember() clears the cleanup target when no branch was preserved. + finishRemoval: (result, _rememberBranch, fallbackHead) => { + host.preservedBranchCleanup.remember( + record.worktreeId, + undefined, + result, + fallbackHead, + removedPushTarget + ) + host.purge(record) + } + }) + host.onRemoved(record) + return result + }, + catalogVersion: () => getLocalWorktreeCatalogVersion(record.repoId), + publish: (outcome) => host.publish(record.repoId, outcome) + } +} + +type InterruptedLocalWorktreeRemovalArgs = Pick< + RuntimeLocalWorktreeRemovalFinishArgs, + 'removedPushTarget' | 'closeWatchers' | 'preserveBranchHead' | 'finishRemoval' +> & { + store: Store + record: WorktreeRemovalRecord + acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise }> + stopSignal: AbortSignal +} + +/** + * Finishes a removal a quit or crash interrupted. What is left comes from Git and disk, not the + * record, so a delete Git already finished (fully or partly) completes the same way. + */ +async function finishInterruptedLocalWorktreeRemoval( + args: InterruptedLocalWorktreeRemovalArgs +): Promise { + const { record, store } = args + const repo = store.getRepo(record.repoId) + if (!repo) { + console.warn(`[worktrees] dropping removal of ${record.worktreePath}: its repo is gone`) + return {} + } + const localOptions = getLocalProjectWorktreeGitOptions(store, repo) + const finishArgs: RuntimeLocalWorktreeRemovalFinishArgs = { + store, + removedPushTarget: args.removedPushTarget, + closeWatchers: args.closeWatchers, + preserveBranchHead: args.preserveBranchHead, + finishRemoval: args.finishRemoval, + repo, + localOptions, + // Why force: Git already deleted part of the checkout, which reads as local changes. + force: true, + deleteBranch: record.deleteBranch, + target: { id: record.worktreeId } + } + const worktrees = await listWorktreesStrict(repo.path, localOptions) + const registered = worktrees.some((worktree) => + areWorktreePathsEqual(worktree.path, record.worktreePath) + ) + const deletable = registered + ? findRegisteredDeletableWorktree( + repo.path, + record.worktreePath, + worktrees, + CLIENT_REMOVAL_HOME + ) + : undefined + if (registered && !deletable) { + throw new Error( + `Worktree registration changed during deletion: ${record.worktreePath}. Retry deletion.` + ) + } + const gate = await args.acquireWatcherRemoval(record.worktreePath) + if (deletable) { + return finishRuntimeLocalWorktreeRemoval(finishArgs, deletable, gate, args.stopSignal) + } + let result: RemoveWorktreeResult + let removed = false + try { + result = await finishUnregisteredWorktreeRemoval( + repo.path, + record.worktreePath, + record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null, + localOptions + ) + removed = true + } finally { + await gate.finish(removed) + } + await cleanupRemovedWorktreePushTarget(finishArgs) + args.finishRemoval(result, true, record.head) + return result +} diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index 9fa4db794083..b8ad001cb1d4 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -27,7 +27,6 @@ import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' -import { normalizeLocalBranchRef } from '../git/worktree-operation-options' import { getLocalWorktreeCatalogVersion } from '../local-worktree-scan-generation' import { removesInBackground, startBackgroundWorktreeRemoval } from '../worktree-background-removal' @@ -157,11 +156,12 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { worktreeId: args.target.id, repoId: repo.id, repoPath: repo.path, - worktreePath: canonicalPath, - branch: normalizeLocalBranchRef(refreshed.branch) + worktree: refreshed, + deleteBranch: args.deleteBranch, + force: args.force }, - run: async () => { - const result = await finishRuntimeLocalWorktreeRemoval(args, refreshed, gate) + run: async (stopSignal) => { + const result = await finishRuntimeLocalWorktreeRemoval(args, refreshed, gate, stopSignal) args.onRemoved() return result }, @@ -171,10 +171,25 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { return { removing: true, ...acceptedFields } } -async function finishRuntimeLocalWorktreeRemoval( - args: Parameters[0], +export type RuntimeLocalWorktreeRemovalFinishArgs = Pick< + Parameters[0], + | 'repo' + | 'removedPushTarget' + | 'store' + | 'localOptions' + | 'force' + | 'deleteBranch' + | 'closeWatchers' + | 'preserveBranchHead' + | 'finishRemoval' +> & { target: { id: string } } + +/** Git's delete and everything after it; the refusals and teardown before it already ran. */ +export async function finishRuntimeLocalWorktreeRemoval( + args: RuntimeLocalWorktreeRemovalFinishArgs, refreshed: GitWorktreeInfo, - gate: { finish: (removed: boolean) => Promise } + gate: { finish: (removed: boolean) => Promise }, + checkoutDeleteSignal?: AbortSignal ): Promise { const { repo, localOptions } = args const canonicalPath = refreshed.path @@ -186,7 +201,8 @@ async function finishRuntimeLocalWorktreeRemoval( await removeWorktree(repo.path, canonicalPath, args.force, { ...(!args.deleteBranch ? { deleteBranch: args.deleteBranch } : {}), knownRemovedWorktree: refreshed, - ...localOptions + ...localOptions, + ...(checkoutDeleteSignal ? { checkoutDeleteSignal } : {}) }), refreshed.head ) @@ -214,7 +230,7 @@ async function finishRuntimeLocalWorktreeRemoval( await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path, ...localOptions }).catch( () => {} ) - await cleanupPushTarget(args) + await cleanupRemovedWorktreePushTarget(args) args.finishRemoval(undefined, false, refreshed.head) completed = true return {} @@ -228,13 +244,13 @@ async function finishRuntimeLocalWorktreeRemoval( } finally { await gate.finish(completed) } - await cleanupPushTarget(args) + await cleanupRemovedWorktreePushTarget(args) args.finishRemoval(removalResult, true, refreshed.head) return removalResult ?? {} } -async function cleanupPushTarget( - args: Parameters[0] +export async function cleanupRemovedWorktreePushTarget( + args: RuntimeLocalWorktreeRemovalFinishArgs ): Promise { await cleanupUnusedWorktreePushTargetRemote( args.repo.path, diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index f104a3ec3439..5a89c9e283f6 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -17,6 +17,7 @@ import { browserManager } from '../browser/browser-manager' import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' import { stopCodexAccountSessionBridges } from '../codex/codex-account-session-bridge' import { awaitPackedRefsLockRelease } from '../git/local-repo-ref-maintenance' +import { stopBackgroundWorktreeRemovals } from '../worktree-background-removal' import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { setUnreadDockBadgeCount } from '../dock/unread-badge' @@ -152,6 +153,8 @@ function installWillQuitHandler(): void { REF_MAINTENANCE_QUIT_DEADLINE_MS ).then(() => {}) state.uninstallRepoMaintenanceIdleGate = null + // Why stop, not wait: a delete can run for minutes, and its record makes the next start finish it. + stopBackgroundWorktreeRemovals() agentHookServer.stop() // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a // bare script path that cannot express the guard and would otherwise keep spawning after quit. diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index c151817b4100..ac8fc8f0d32b 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -32,6 +32,7 @@ import { import { initializeMainProcessAutomations } from './main-process-automations' import { initializeMainProcessPlugins } from './main-process-plugins' import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from '../worktree-trash' +import { loadWorktreeRemovalRecords } from '../worktree-background-removal' import { runAfterFirstWindowShown } from './first-window-deferral' import { logStartupMilestone } from './startup-diagnostics' @@ -43,6 +44,8 @@ export async function initializeReadyRuntimeServices(): Promise { if (!store) { throw new Error('Store must be initialized before ready services') } + // Why before any listing: a delete a quit or crash interrupted must show as Deleting from first paint. + await loadWorktreeRemovalRecords(store.getProfileStorageDirectory()) initializeMainProcessObservers() initializeMainProcessAccountServices() const runtime = initializeMainProcessRuntime() @@ -78,15 +81,16 @@ export async function initializeReadyRuntimeServices(): Promise { state.emulatorBridge = new EmulatorBridge() runtime.setEmulatorBridge(state.emulatorBridge) // Why: older releases renamed removed checkouts into a trash root and deleted them in the background, - // so a quit mid-delete left directories on disk; drain them. Why deferred: the sweep's recursive - // readdir/rm runs on the same libuv threadpool the window's first paint and worktree-catalog - // hydration are reading disk on, and nothing on the startup path consumes its result. + // so a quit mid-delete left directories on disk; drain them. Removals this version recorded are + // finished by the same delete. Why deferred: both touch disk on the same libuv threadpool the + // window's first paint and worktree-catalog hydration read on, and startup consumes neither. runAfterFirstWindowShown(() => { void sweepStaleWorktreeTrash( collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) ).catch((error) => { console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) }) + runtime.finishInterruptedWorktreeRemovals() }, WORKTREE_TRASH_SWEEP_FALLBACK_MS) nativeTheme.themeSource = store.getSettings().theme ?? 'system' // Why (#16441): the real-home grant runs a codex app-server session. It stays diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts new file mode 100644 index 000000000000..e4280878da2d --- /dev/null +++ b/src/main/worktree-background-removal-records.test.ts @@ -0,0 +1,199 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeRemovalOutcome } from '../shared/worktree/removal-outcome' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests, + isWorktreeRemovalPending, + loadWorktreeRemovalRecords, + projectPendingWorktreeRemovals, + resumeInterruptedWorktreeRemovals, + startBackgroundWorktreeRemoval, + stopBackgroundWorktreeRemovals +} from './worktree-background-removal' +import { readWorktreeRemovalRecords, worktreeRemovalRecordsFile } from './worktree-removal-records' + +const removal = { + worktreeId: 'repo-1::/work/feature', + repoId: 'repo-1', + repoPath: '/work/repo', + worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, + deleteBranch: true, + force: false +} +const catalogVersion = { epoch: 'e', sequence: 1 } +let directory = '' + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-worktree-removal-records-')) + await loadWorktreeRemovalRecords(directory) +}) + +afterEach(async () => { + _resetPendingWorktreeRemovalsForTests() + await rm(directory, { recursive: true, force: true }) +}) + +/** A delete Git never finishes on its own, like one a quit or crash cuts short. */ +function interruptedJob(): { + run: (stopSignal: AbortSignal) => Promise + catalogVersion: () => typeof catalogVersion + publish: (outcome?: WorktreeRemovalOutcome) => void + started: () => boolean + stopped: () => boolean +} { + let started = false + let stopped = false + return { + run: (stopSignal) => + new Promise((_resolve, reject) => { + started = true + stopSignal.addEventListener('abort', () => { + stopped = true + reject(new Error('The operation was aborted.')) + }) + }), + catalogVersion: () => catalogVersion, + publish: vi.fn(), + started: () => started, + stopped: () => stopped + } +} + +describe('durable worktree removal records', () => { + it('writes the record before Git starts and clears it once the delete succeeds', async () => { + let recordedAtGitStart: unknown[] = [] + startBackgroundWorktreeRemoval({ + removal, + run: async () => { + recordedAtGitStart = await readWorktreeRemovalRecords(directory) + return {} + }, + catalogVersion: () => catalogVersion, + publish: () => {} + }) + await _settlePendingWorktreeRemovalsForTests() + + expect(recordedAtGitStart).toEqual([ + { + worktreeId: removal.worktreeId, + repoId: 'repo-1', + repoPath: '/work/repo', + worktreePath: '/work/feature', + branch: 'feature', + head: 'abc', + deleteBranch: true, + force: false, + requestedAt: expect.any(Number) + } + ]) + expect(await readWorktreeRemovalRecords(directory)).toEqual([]) + }) + + it('clears the record when the delete fails so the row returns live and retryable', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const publish = vi.fn() + startBackgroundWorktreeRemoval({ + removal, + run: async () => { + throw new Error('Permission denied') + }, + catalogVersion: () => catalogVersion, + publish + }) + await _settlePendingWorktreeRemovalsForTests() + + expect(await readWorktreeRemovalRecords(directory)).toEqual([]) + expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) + expect(publish).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'failed', error: 'Permission denied' }) + ) + }) + + it('stops Git on quit without waiting and keeps the record for the next start', async () => { + const job = interruptedJob() + startBackgroundWorktreeRemoval({ removal, ...job }) + await vi.waitFor(() => expect(job.started()).toBe(true)) + + expect(stopBackgroundWorktreeRemovals()).toBeUndefined() + expect(job.stopped()).toBe(true) + await _settlePendingWorktreeRemovalsForTests() + + expect(await readWorktreeRemovalRecords(directory)).toHaveLength(1) + // Only the start was published: a stopped delete is neither removed nor failed. + expect(job.publish).toHaveBeenCalledTimes(1) + }) + + it('marks the row as removing after a restart and finishes it with the same job', async () => { + const job = interruptedJob() + startBackgroundWorktreeRemoval({ removal, ...job }) + await vi.waitFor(() => expect(job.started()).toBe(true)) + stopBackgroundWorktreeRemovals() + await _settlePendingWorktreeRemovalsForTests() + + // Restart: nothing in memory, only the file. + _resetPendingWorktreeRemovalsForTests() + expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) + await loadWorktreeRemovalRecords(directory) + const rows = [{ id: removal.worktreeId }, { id: 'repo-1::/work/other' }] + expect(projectPendingWorktreeRemovals(rows, (row) => row.id, true)).toEqual([ + { id: removal.worktreeId, removing: true }, + { id: 'repo-1::/work/other' } + ]) + expect(projectPendingWorktreeRemovals(rows, (row) => row.id, false)).toEqual([ + { id: 'repo-1::/work/other' } + ]) + + const publish = vi.fn() + const resumed: string[] = [] + resumeInterruptedWorktreeRemovals((record) => ({ + run: async () => { + resumed.push(`${record.worktreePath} ${record.branch} ${record.deleteBranch}`) + return {} + }, + catalogVersion: () => catalogVersion, + publish + })) + await _settlePendingWorktreeRemovalsForTests() + + expect(resumed).toEqual(['/work/feature feature true']) + expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) + expect(await readWorktreeRemovalRecords(directory)).toEqual([]) + expect(publish).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: removal.worktreeId, status: 'removed' }) + ) + }) + + it('does not start a second job for a removal that is still running', async () => { + startBackgroundWorktreeRemoval({ removal, ...interruptedJob() }) + const jobFor = vi.fn() + resumeInterruptedWorktreeRemovals(jobFor) + expect(jobFor).not.toHaveBeenCalled() + stopBackgroundWorktreeRemovals() + await _settlePendingWorktreeRemovalsForTests() + }) + + it('reads back only well-formed records from the file', async () => { + const valid = { + worktreeId: 'repo-1::/work/a', + repoId: 'repo-1', + repoPath: '/work/repo', + worktreePath: '/work/a', + branch: '', + head: '', + deleteBranch: false, + force: true, + requestedAt: 5 + } + await writeFile( + worktreeRemovalRecordsFile(directory), + JSON.stringify({ version: 1, removals: [valid, { worktreeId: 'x' }, null] }) + ) + expect(await readWorktreeRemovalRecords(directory)).toEqual([valid]) + + await writeFile(worktreeRemovalRecordsFile(directory), '{not json') + expect(await readWorktreeRemovalRecords(directory)).toEqual([]) + }) +}) diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts index 79fbad67e7cf..4876e6b2b619 100644 --- a/src/main/worktree-background-removal.test.ts +++ b/src/main/worktree-background-removal.test.ts @@ -14,8 +14,9 @@ const removal = { worktreeId: 'repo-1::/work/feature', repoId: 'repo-1', repoPath: '/work/repo', - worktreePath: '/work/feature', - branch: 'feature' + worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, + deleteBranch: true, + force: false } const catalogVersion = { epoch: 'e', sequence: 7 } diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 1210584ed68b..df92535fbb04 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -2,22 +2,50 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/executi import type { RemoveWorktreeResult } from '../shared/worktree/create-types' import type { WorktreeCatalogVersion } from '../shared/worktree/catalog-version' import type { WorktreeRemovalOutcome } from '../shared/worktree/removal-outcome' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { normalizeLocalBranchRef } from './git/worktree-operation-options' import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { parseWslPath } from './wsl' +import { + readWorktreeRemovalRecords, + writeWorktreeRemovalRecords, + type WorktreeRemovalRecord +} from './worktree-removal-records' -export type PendingWorktreeRemoval = { - worktreeId: string - repoId: string - repoPath: string - worktreePath: string - /** Short branch name the checkout had; empty when detached. */ - branch: string +export type BackgroundWorktreeRemovalJob = { + /** `stopSignal` aborts on an orderly quit; pass it only to the checkout delete. */ + run: (stopSignal: AbortSignal) => Promise + catalogVersion: () => WorktreeCatalogVersion + publish: (outcome?: WorktreeRemovalOutcome) => void +} + +// The accepted removals, mirrored to disk on every change; listings and joins read only this. +const pendingByWorktreeId = new Map() +const jobsByWorktreeId = new Map>() +const stopControllers = new Set() +let recordsDirectory: string | null = null + +/** Loads removals a quit or crash interrupted, so listings mark them before the first paint. */ +export async function loadWorktreeRemovalRecords(directory: string): Promise { + recordsDirectory = directory + for (const record of await readWorktreeRemovalRecords(directory)) { + if (!pendingByWorktreeId.has(record.worktreeId)) { + pendingByWorktreeId.set(record.worktreeId, record) + } + } } -// Why in memory only: after a crash Git still lists the checkout, so it returns as a normal row -// and a second remove finishes it. Nothing here outlives the process that runs the delete. -const pendingByWorktreeId = new Map() -const runningJobs = new Set>() +function persistRecords(): Promise { + if (!recordsDirectory) { + return Promise.resolve() + } + return writeWorktreeRemovalRecords(recordsDirectory, () => [ + ...pendingByWorktreeId.values() + ]).catch((error: unknown) => { + // Why: bookkeeping must not gate the delete; a lost write only costs resuming it after a quit. + console.warn('[worktrees] failed to persist worktree removal records', error) + }) +} /** Only this host's local checkouts are removed in the background. */ export function isWorktreeRemovalPending(worktreeId: string, hostId?: ExecutionHostId): boolean { @@ -41,7 +69,7 @@ export function hasPendingWorktreeRemovals(): boolean { export function findPendingWorktreeRemovalConflict( repoPath: string, target: { worktreePath?: string; branch?: string } -): PendingWorktreeRemoval | undefined { +): WorktreeRemovalRecord | undefined { const branch = target.branch?.replace(/^refs\/heads\//, '') for (const removal of pendingByWorktreeId.values()) { if (!areWorktreePathsEqual(removal.repoPath, repoPath)) { @@ -73,45 +101,104 @@ export function assertNoPendingWorktreeRemovalConflict( * Records an accepted removal and runs its delete off the request. `publish` fires once when the * row starts showing as removing, and once with the outcome after the row has left the table. */ -export function startBackgroundWorktreeRemoval(args: { - removal: PendingWorktreeRemoval - run: () => Promise - catalogVersion: () => WorktreeCatalogVersion - publish: (outcome?: WorktreeRemovalOutcome) => void -}): void { - const { removal } = args - pendingByWorktreeId.set(removal.worktreeId, removal) - const job = settleBackgroundWorktreeRemoval(args) - runningJobs.add(job) - void job.finally(() => runningJobs.delete(job)) +export function startBackgroundWorktreeRemoval( + args: { + removal: Pick< + WorktreeRemovalRecord, + 'worktreeId' | 'repoId' | 'repoPath' | 'deleteBranch' | 'force' + > & { worktree: Pick } + } & BackgroundWorktreeRemovalJob +): void { + const { worktree, ...accepted } = args.removal + const record: WorktreeRemovalRecord = { + ...accepted, + worktreePath: worktree.path, + branch: normalizeLocalBranchRef(worktree.branch), + head: worktree.head, + requestedAt: Date.now() + } + pendingByWorktreeId.set(record.worktreeId, record) + runBackgroundWorktreeRemoval(record, args, persistRecords()) publishSafely(args.publish, undefined) } +/** Runs the same delete again for every record a quit or crash left without a running job. */ +export function resumeInterruptedWorktreeRemovals( + jobFor: (record: WorktreeRemovalRecord) => BackgroundWorktreeRemovalJob +): void { + for (const record of pendingByWorktreeId.values()) { + if (!jobsByWorktreeId.has(record.worktreeId)) { + runBackgroundWorktreeRemoval(record, jobFor(record), Promise.resolve()) + } + } +} + +/** Orderly quit: stops each checkout delete Git is running, without waiting for it to exit. */ +export function stopBackgroundWorktreeRemovals(): void { + for (const controller of stopControllers) { + controller.abort() + } +} + +function runBackgroundWorktreeRemoval( + record: WorktreeRemovalRecord, + job: BackgroundWorktreeRemovalJob, + recorded: Promise +): void { + const controller = new AbortController() + stopControllers.add(controller) + const settled: Promise = settleBackgroundWorktreeRemoval( + record, + job, + recorded, + controller.signal + ).finally(() => { + stopControllers.delete(controller) + if (jobsByWorktreeId.get(record.worktreeId) === settled) { + jobsByWorktreeId.delete(record.worktreeId) + } + }) + jobsByWorktreeId.set(record.worktreeId, settled) +} + async function settleBackgroundWorktreeRemoval( - args: Parameters[0] + record: WorktreeRemovalRecord, + job: BackgroundWorktreeRemovalJob, + recorded: Promise, + stopSignal: AbortSignal ): Promise { - const { removal } = args + // Why: the record is the commit point, so Git starts deleting only once it is on disk. + await recorded let outcome: WorktreeRemovalOutcome try { - const result = await args.run() + if (stopSignal.aborted) { + return + } + const result = await job.run(stopSignal) outcome = { - worktreeId: removal.worktreeId, + worktreeId: record.worktreeId, status: 'removed', ...(result.preservedBranch ? { preservedBranch: result.preservedBranch } : {}), - catalogVersion: args.catalogVersion() + catalogVersion: job.catalogVersion() } } catch (error) { - console.warn(`[worktrees] background removal of ${removal.worktreePath} failed`, error) + if (stopSignal.aborted) { + // Why: quit stopped Git; the record stays so the next start finishes this delete. + return + } + console.warn(`[worktrees] background removal of ${record.worktreePath} failed`, error) outcome = { - worktreeId: removal.worktreeId, + worktreeId: record.worktreeId, status: 'failed', error: error instanceof Error ? error.message : String(error) } } - if (pendingByWorktreeId.get(removal.worktreeId) === removal) { - pendingByWorktreeId.delete(removal.worktreeId) + // Why clear on failure too: the row returns live and retryable instead of retrying unseen. + if (pendingByWorktreeId.get(record.worktreeId) === record) { + pendingByWorktreeId.delete(record.worktreeId) + await persistRecords() } - publishSafely(args.publish, outcome) + publishSafely(job.publish, outcome) } function publishSafely( @@ -151,12 +238,14 @@ export function projectPendingWorktreeRemovals< } export async function _settlePendingWorktreeRemovalsForTests(): Promise { - while (runningJobs.size > 0) { - await Promise.all(runningJobs) + while (jobsByWorktreeId.size > 0) { + await Promise.all(jobsByWorktreeId.values()) } } export function _resetPendingWorktreeRemovalsForTests(): void { pendingByWorktreeId.clear() - runningJobs.clear() + jobsByWorktreeId.clear() + stopControllers.clear() + recordsDirectory = null } diff --git a/src/main/worktree-removal-records.ts b/src/main/worktree-removal-records.ts new file mode 100644 index 000000000000..1fd3558d232a --- /dev/null +++ b/src/main/worktree-removal-records.ts @@ -0,0 +1,93 @@ +// Why a sidecar beside the profile state: the record must be on disk before Git starts deleting, +// and profile-state writes are batched; the sidecar writer fsyncs and renames atomically. It also +// keeps a host-local, in-flight obligation out of profile exports and backups. +import { + readSidecarSnapshot, + sidecarSnapshotFile, + withSidecarSnapshotQueue, + writeSidecarSnapshot +} from './sidecar-snapshot-file' + +const RECORDS_FILE_NAME = 'orca-worktree-removals.json' +const RECORDS_VERSION = 1 + +/** A local worktree removal Orca accepted; everything a restart needs to run the same delete. */ +export type WorktreeRemovalRecord = { + worktreeId: string + repoId: string + repoPath: string + worktreePath: string + /** Short branch name the checkout had; empty when detached. */ + branch: string + /** Branch head when the removal was accepted; empty when unknown. */ + head: string + deleteBranch: boolean + force: boolean + requestedAt: number +} + +type PersistedWorktreeRemovalRecords = { + version: number + removals: WorktreeRemovalRecord[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseRecord(value: unknown): WorktreeRemovalRecord | null { + if ( + !isRecord(value) || + typeof value.worktreeId !== 'string' || + typeof value.repoId !== 'string' || + typeof value.repoPath !== 'string' || + typeof value.worktreePath !== 'string' || + typeof value.branch !== 'string' || + typeof value.head !== 'string' || + typeof value.deleteBranch !== 'boolean' || + typeof value.force !== 'boolean' || + typeof value.requestedAt !== 'number' + ) { + return null + } + return { + worktreeId: value.worktreeId, + repoId: value.repoId, + repoPath: value.repoPath, + worktreePath: value.worktreePath, + branch: value.branch, + head: value.head, + deleteBranch: value.deleteBranch, + force: value.force, + requestedAt: value.requestedAt + } +} + +export function worktreeRemovalRecordsFile(directory: string): string { + return sidecarSnapshotFile(directory, RECORDS_FILE_NAME) +} + +/** Records a previous run left behind; a missing or unreadable file reads as none. */ +export async function readWorktreeRemovalRecords( + directory: string +): Promise { + const parsed = await readSidecarSnapshot(worktreeRemovalRecordsFile(directory)) + if (!isRecord(parsed) || parsed.version !== RECORDS_VERSION || !Array.isArray(parsed.removals)) { + return [] + } + return parsed.removals.flatMap((entry) => parseRecord(entry) ?? []) +} + +/** Writes the current records; `current` is read when the queued write runs, so the latest wins. */ +export function writeWorktreeRemovalRecords( + directory: string, + current: () => WorktreeRemovalRecord[] +): Promise { + const file = worktreeRemovalRecordsFile(directory) + return withSidecarSnapshotQueue(file, () => + writeSidecarSnapshot(file, { + version: RECORDS_VERSION, + removals: current() + } satisfies PersistedWorktreeRemovalRecords) + ) +} diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index d0572bd847d6..3f20b3870ad8 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -465,7 +465,7 @@ describe('useIpcEvents App-lifetime lifecycle', () => { expect( [...listeners.values()].every((records) => records.filter((item) => item.active).length === 1) ).toBe(true) - expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(2) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(3) firstCleanup() const ipcCleanupOrder = cleanupOrder @@ -477,9 +477,11 @@ describe('useIpcEvents App-lifetime lifecycle', () => { 'mobile.disposeHydration', 'store.unsubscribe.0', 'runtimeStore.unsubscribe', - 'store.unsubscribe.1', + 'store.unsubscribe.2', 'agentStore.unsubscribe' ]) + // The background-removal bridge's row subscription, released with the rest of `unsubs`. + expect(cleanupOrder).toContain('store.unsubscribe.1') expect(cleanupOrder.indexOf('runtimeEnvironment.unsubscribe')).toBeGreaterThan( cleanupOrder.indexOf('ipc.ui.onMobileMarkdownRequest') ) @@ -512,7 +514,7 @@ describe('useIpcEvents App-lifetime lifecycle', () => { expect( [...listeners.values()].every((records) => records.filter((item) => item.active).length === 1) ).toBe(true) - expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(2) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(3) secondCleanup() expect([...listeners.values()].every((records) => records.every((item) => !item.active))).toBe( diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 7e17e914433f..db94537297be 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -17,6 +17,7 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY @@ -95,7 +96,8 @@ describe('WebRuntimeClient', () => { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, - WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY ] }) From 2d71eb98661f4d386e8681316e672bb7f4cba292 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:13:17 -0700 Subject: [PATCH 15/46] test(worktrees): type the interrupted-removal assertions for the node typecheck --- .../runtime-interrupted-local-worktree-removal.test.ts | 8 ++++++-- src/main/worktree-background-removal-records.test.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index 141c5fa25cb7..d95a3502376f 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -125,7 +125,9 @@ describe('finishing an interrupted worktree removal after a restart', () => { const { outcome, purged } = await finishAfterRestart() expect(outcome).toMatchObject({ status: 'removed' }) - expect(outcome?.preservedBranch).toBeUndefined() + expect( + outcome && 'preservedBranch' in outcome ? outcome.preservedBranch : undefined + ).toBeUndefined() expect(existsSync(worktreePath)).toBe(false) expect(await git(['worktree', 'list'])).not.toContain(worktreePath) expect(await git(['branch', '--list', 'feature'])).toBe('') @@ -173,7 +175,9 @@ describe('finishing an interrupted worktree removal after a restart', () => { const { outcome, purged } = await finishAfterRestart({ head }) expect(outcome).toMatchObject({ status: 'removed' }) - expect(outcome?.preservedBranch).toBeUndefined() + expect( + outcome && 'preservedBranch' in outcome ? outcome.preservedBranch : undefined + ).toBeUndefined() expect(purged).toEqual([`repo-1::${worktreePath}`]) }) diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index e4280878da2d..2b9d99f37b9a 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -137,7 +137,10 @@ describe('durable worktree removal records', () => { _resetPendingWorktreeRemovalsForTests() expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) await loadWorktreeRemovalRecords(directory) - const rows = [{ id: removal.worktreeId }, { id: 'repo-1::/work/other' }] + const rows: { id: string; hostId?: undefined }[] = [ + { id: removal.worktreeId }, + { id: 'repo-1::/work/other' } + ] expect(projectPendingWorktreeRemovals(rows, (row) => row.id, true)).toEqual([ { id: removal.worktreeId, removing: true }, { id: 'repo-1::/work/other' } From 09f75c41e87af2943cafc5f06b1eaf0b9187c004 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:23:38 -0700 Subject: [PATCH 16/46] fix(worktrees): finish an interrupted delete that already removed the checkout's .git file Quit stops git worktree remove mid-delete, and Git deletes the checkout's .git file wherever it falls in directory order. Git then refuses the checkout ("validation failed ... .git does not exist") on every retry, so the startup finish failed and the row could never be deleted from Orca. A registered checkout this record owns that has lost its .git file now finishes like an unregistered one: leftover files, prune, then the branch. --- ...interrupted-local-worktree-removal.test.ts | 25 ++++++++++++++++++ ...time-interrupted-local-worktree-removal.ts | 26 ++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index d95a3502376f..5ac00f08eb4c 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -134,6 +134,31 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(purged).toEqual([`repo-1::${worktreePath}`]) }) + it('finishes a checkout whose .git file Git had already deleted', async () => { + // Git deletes in directory order; without `.git` it refuses the checkout ("validation failed"). + await unlink(join(worktreePath, '.git')) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(existsSync(worktreePath)).toBe(false) + expect(await git(['worktree', 'list'])).not.toContain(worktreePath) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([`repo-1::${worktreePath}`]) + }) + + it('leaves a locked checkout alone even when its .git file is gone', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await git(['worktree', 'lock', worktreePath]) + await unlink(join(worktreePath, '.git')) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) + expect(purged).toEqual([]) + }) + it('deletes the branch when Git finished the checkout but the quit came before the branch', async () => { await git(['worktree', 'remove', worktreePath]) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 0ad1d35b3417..4c743e1e60fe 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -1,4 +1,7 @@ +import { lstat } from 'node:fs/promises' +import { join } from 'node:path' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { Store } from '../persistence' @@ -8,6 +11,7 @@ import { resolveWorktreeRemovalMetadata } from '../worktree-removal-repo-owner' import type { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' import { listWorktreesStrict } from '../git/worktree' import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' +import { getErrorCode } from '../git/worktree-operation-options' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' @@ -126,8 +130,14 @@ async function finishInterruptedLocalWorktreeRemoval( `Worktree registration changed during deletion: ${record.worktreePath}. Retry deletion.` ) } + // Why: Git deletes `.git` wherever it falls in directory order (early on NTFS) and refuses to + // remove a checkout left without it; its registration and this record prove the rest is ours. + const lostGitLink = deletable ? await isCheckoutMissingGitLink(record.worktreePath) : false + if (lostGitLink) { + assertWorktreeUnlockedForRemoval(deletable) + } const gate = await args.acquireWatcherRemoval(record.worktreePath) - if (deletable) { + if (deletable && !lostGitLink) { return finishRuntimeLocalWorktreeRemoval(finishArgs, deletable, gate, args.stopSignal) } let result: RemoveWorktreeResult @@ -147,3 +157,17 @@ async function finishInterruptedLocalWorktreeRemoval( args.finishRemoval(result, true, record.head) return result } + +async function isCheckoutMissingGitLink(worktreePath: string): Promise { + try { + await lstat(worktreePath) + } catch { + return false + } + try { + await lstat(join(worktreePath, '.git')) + return false + } catch (error) { + return getErrorCode(error) === 'ENOENT' + } +} From e6fa1a9135db90b360dd42a0e7f2645173b6aec8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:40:43 -0700 Subject: [PATCH 17/46] fix(worktrees): let Git finish an interrupted delete, and never take a different checkout A quit or crash that stops `git worktree remove` after it deleted the checkout's .git file left a registered checkout Git refuses to remove. The previous fix deleted that leftover inside Orca's process, which is the bulk delete this change exists to avoid (and on Windows the leftover can be most of the checkout). The startup finish now rewrites the missing .git file from Git's own admin entry for that path and lets `git worktree remove --force` delete it. `git worktree repair` is not used: it also re-points every other registered path, including a checkout another repository now owns there. Orca deletes the leftover itself only when no admin entry claims the path. The startup finish forces, so it now leaves the path alone when the checkout there is not the one recorded: a registered worktree on a different branch or head, or a `.git` at a path Git already unregistered. The record is dropped and the card shows why. The record write before Git starts is now bounded (2 s, logged when exceeded) so a stalled disk cannot hold the delete, and the outcome is published before the record's clear reaches disk. --- src/main/git/worktree-git-file-restore.ts | 58 +++++++++++++ ...interrupted-local-worktree-removal.test.ts | 82 ++++++++++++++++++- ...time-interrupted-local-worktree-removal.ts | 42 ++++++++-- ...orktree-background-removal-records.test.ts | 60 +++++++++++++- src/main/worktree-background-removal.ts | 35 +++++++- 5 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 src/main/git/worktree-git-file-restore.ts diff --git a/src/main/git/worktree-git-file-restore.ts b/src/main/git/worktree-git-file-restore.ts new file mode 100644 index 000000000000..616aacb2549b --- /dev/null +++ b/src/main/git/worktree-git-file-restore.ts @@ -0,0 +1,58 @@ +import { readdir, readFile, writeFile } from 'node:fs/promises' +import { isAbsolute, join } from 'node:path' +import type { GitWorktreeExecOptions } from './worktree-operation-options' +import { getErrorCode } from './worktree-operation-options' +import { readRepoCommonDirFromGit } from './worktree-list-reader' +import { areWorktreePathsEqual } from './worktree-path-comparison' + +/** + * Rewrites a registered linked worktree's missing `.git` file from Git's own admin entry, the one + * whose `gitdir` points back at it, so `git worktree remove` can validate and delete the checkout. + * + * Why not `git worktree repair` (2.29+): it also re-points every other registered path's `.git`, + * including a checkout another repository now owns at a reused path. + */ +export async function restoreMissingWorktreeGitFile( + repoPath: string, + worktreePath: string, + options: GitWorktreeExecOptions = {} +): Promise { + const commonDir = await readRepoCommonDirFromGit(repoPath, options) + if (!commonDir) { + return false + } + const adminRoot = join(commonDir, 'worktrees') + let entryNames: string[] + try { + entryNames = (await readdir(adminRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + } catch { + return false + } + const gitFilePath = join(worktreePath, '.git') + const owners: string[] = [] + for (const name of entryNames) { + const entryDir = join(adminRoot, name) + const backlink = await readFile(join(entryDir, 'gitdir'), 'utf8').then( + (content) => content.trim(), + () => '' + ) + const resolved = backlink && (isAbsolute(backlink) ? backlink : join(entryDir, backlink)) + if (resolved && areWorktreePathsEqual(resolved, gitFilePath)) { + owners.push(entryDir) + } + } + if (owners.length !== 1) { + return false + } + // Forward slashes are what Git itself writes, and what it compares against on Windows. + const adminDir = process.platform === 'win32' ? owners[0].replaceAll('\\', '/') : owners[0] + try { + await writeFile(gitFilePath, `gitdir: ${adminDir}\n`, { flag: 'wx' }) + } catch (error) { + // Something recreated `.git` meanwhile; Git's own validation judges it. + return getErrorCode(error) === 'EEXIST' + } + return true +} diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index 5ac00f08eb4c..79e79f563e44 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -10,6 +10,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import type { Store } from '../persistence' +import type * as HostTreeRemoval from '../host-tree-removal' +import { removeHostTree } from '../host-tree-removal' +import type * as WorktreeGitFileRestore from '../git/worktree-git-file-restore' +import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, @@ -27,6 +31,17 @@ import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local- vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: () => ({}) })) +vi.mock('../host-tree-removal', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, removeHostTree: vi.fn(actual.removeHostTree) } +}) +vi.mock('../git/worktree-git-file-restore', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + restoreMissingWorktreeGitFile: vi.fn(actual.restoreMissingWorktreeGitFile) + } +}) const execFileAsync = promisify(execFile) @@ -61,6 +76,7 @@ beforeEach(async () => { afterEach(async () => { _resetPendingWorktreeRemovalsForTests() + vi.mocked(removeHostTree).mockClear() await rm(scratchDir, { recursive: true, force: true }) }) @@ -134,9 +150,10 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(purged).toEqual([`repo-1::${worktreePath}`]) }) - it('finishes a checkout whose .git file Git had already deleted', async () => { + it('lets Git finish a checkout whose .git file it had already deleted', async () => { // Git deletes in directory order; without `.git` it refuses the checkout ("validation failed"). await unlink(join(worktreePath, '.git')) + await unlink(join(worktreePath, 'seed.txt')) const { outcome, purged } = await finishAfterRestart() @@ -145,6 +162,69 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(await git(['worktree', 'list'])).not.toContain(worktreePath) expect(await git(['branch', '--list', 'feature'])).toBe('') expect(purged).toEqual([`repo-1::${worktreePath}`]) + // Git's process deleted it, not Orca's. + expect(removeHostTree).not.toHaveBeenCalled() + }) + + it('deletes the leftover itself only when Git cannot be pointed back at it', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await unlink(join(worktreePath, '.git')) + vi.mocked(restoreMissingWorktreeGitFile).mockResolvedValueOnce(false) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(removeHostTree).toHaveBeenCalledWith(worktreePath) + expect(existsSync(worktreePath)).toBe(false) + expect(await git(['worktree', 'list'])).not.toContain(worktreePath) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([`repo-1::${worktreePath}`]) + }) + + it('leaves a different checkout created at the same path since the quit', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const head = (await git(['rev-parse', 'feature'])).trim() + await git(['worktree', 'remove', worktreePath]) + await git(['worktree', 'add', '-q', worktreePath, '-b', 'other']) + await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') + + const { outcome, purged } = await finishAfterRestart({ head }) + + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) + expect(await git(['worktree', 'list'])).toContain(worktreePath) + expect(purged).toEqual([]) + }) + + it('leaves a repository created at the path after Git unregistered the checkout', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await git(['worktree', 'remove', worktreePath]) + await mkdir(worktreePath, { recursive: true }) + await git(['init', '-q'], worktreePath) + await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + }) + + it('leaves the same branch checked out again at a new head', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const head = (await git(['rev-parse', 'feature'])).trim() + await git(['worktree', 'remove', worktreePath]) + await git(['worktree', 'add', '-q', worktreePath, 'feature']) + await writeFile(join(worktreePath, 'work.txt'), 'work\n') + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'work'], worktreePath) + + const { outcome, purged } = await finishAfterRestart({ head }) + + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(join(worktreePath, 'work.txt'))).toBe(true) + expect(purged).toEqual([]) }) it('leaves a locked checkout alone even when its .git file is gone', async () => { diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 4c743e1e60fe..83ea1602b8fc 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -1,6 +1,7 @@ import { lstat } from 'node:fs/promises' import { join } from 'node:path' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -11,7 +12,8 @@ import { resolveWorktreeRemovalMetadata } from '../worktree-removal-repo-owner' import type { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' import { listWorktreesStrict } from '../git/worktree' import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' -import { getErrorCode } from '../git/worktree-operation-options' +import { getErrorCode, normalizeLocalBranchRef } from '../git/worktree-operation-options' +import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' @@ -130,16 +132,28 @@ async function finishInterruptedLocalWorktreeRemoval( `Worktree registration changed during deletion: ${record.worktreePath}. Retry deletion.` ) } + const gitLink = await readCheckoutGitLink(record.worktreePath) + // Why: the finish forces, so a checkout created at this path since the quit must not be taken. + // Git deletes the checkout, `.git` included, before it drops the registration, so a `.git` at an + // unregistered path belongs to a new checkout. + if (deletable ? !isRecordedCheckout(deletable, record) : gitLink === 'present') { + throw new Error( + `A different checkout is now at ${record.worktreePath}; Orca left it in place. Delete it again to remove it.` + ) + } // Why: Git deletes `.git` wherever it falls in directory order (early on NTFS) and refuses to - // remove a checkout left without it; its registration and this record prove the rest is ours. - const lostGitLink = deletable ? await isCheckoutMissingGitLink(record.worktreePath) : false - if (lostGitLink) { + // remove a checkout left without it; restoring the link from Git's admin entry lets Git finish. + let gitCanRemove = !!deletable + if (deletable && gitLink === 'missing') { assertWorktreeUnlockedForRemoval(deletable) + gitCanRemove = await restoreMissingWorktreeGitFile(repo.path, deletable.path, localOptions) } const gate = await args.acquireWatcherRemoval(record.worktreePath) - if (deletable && !lostGitLink) { + if (deletable && gitCanRemove) { return finishRuntimeLocalWorktreeRemoval(finishArgs, deletable, gate, args.stopSignal) } + // Unregistered, or no admin entry claims the checkout so Git cannot validate it: the leftover is + // deleted in this process as a last resort, then pruned. let result: RemoveWorktreeResult let removed = false try { @@ -158,16 +172,26 @@ async function finishInterruptedLocalWorktreeRemoval( return result } -async function isCheckoutMissingGitLink(worktreePath: string): Promise { +function isRecordedCheckout(worktree: GitWorktreeInfo, record: WorktreeRemovalRecord): boolean { + return ( + normalizeLocalBranchRef(worktree.branch) === record.branch && + (!record.head || worktree.head === record.head) + ) +} + +/** `missing`: the checkout directory is there without its `.git`; unreadable counts as present. */ +async function readCheckoutGitLink( + worktreePath: string +): Promise<'no-checkout' | 'missing' | 'present'> { try { await lstat(worktreePath) } catch { - return false + return 'no-checkout' } try { await lstat(join(worktreePath, '.git')) - return false + return 'present' } catch (error) { - return getErrorCode(error) === 'ENOENT' + return getErrorCode(error) === 'ENOENT' ? 'missing' : 'present' } } diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index 2b9d99f37b9a..04dc5903aa76 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -13,7 +13,20 @@ import { startBackgroundWorktreeRemoval, stopBackgroundWorktreeRemovals } from './worktree-background-removal' -import { readWorktreeRemovalRecords, worktreeRemovalRecordsFile } from './worktree-removal-records' +import type * as WorktreeRemovalRecords from './worktree-removal-records' +import { + readWorktreeRemovalRecords, + worktreeRemovalRecordsFile, + writeWorktreeRemovalRecords +} from './worktree-removal-records' + +vi.mock('./worktree-removal-records', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeWorktreeRemovalRecords: vi.fn(actual.writeWorktreeRemovalRecords) + } +}) const removal = { worktreeId: 'repo-1::/work/feature', @@ -32,6 +45,7 @@ beforeEach(async () => { }) afterEach(async () => { + vi.useRealTimers() _resetPendingWorktreeRemovalsForTests() await rm(directory, { recursive: true, force: true }) }) @@ -112,6 +126,50 @@ describe('durable worktree removal records', () => { ) }) + it('starts Git after a bounded wait when the record write stalls', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + vi.mocked(writeWorktreeRemovalRecords).mockReturnValueOnce(new Promise(() => {})) + const run = vi.fn(async () => ({})) + startBackgroundWorktreeRemoval({ + removal, + run, + catalogVersion: () => catalogVersion, + publish: () => {} + }) + + await vi.advanceTimersByTimeAsync(1_000) + expect(run).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1_000) + expect(run).toHaveBeenCalledTimes(1) + vi.useRealTimers() + await _settlePendingWorktreeRemovalsForTests() + }) + + it('publishes the outcome without waiting for the record to be cleared on disk', async () => { + let finishClear = (): void => {} + const publish = vi.fn() + startBackgroundWorktreeRemoval({ + removal, + run: async () => { + vi.mocked(writeWorktreeRemovalRecords).mockReturnValueOnce( + new Promise((resolve) => { + finishClear = resolve + }) + ) + return {} + }, + catalogVersion: () => catalogVersion, + publish + }) + + await vi.waitFor(() => + expect(publish).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'removed' })) + ) + finishClear() + await _settlePendingWorktreeRemovalsForTests() + }) + it('stops Git on quit without waiting and keeps the record for the next start', async () => { const job = interruptedJob() startBackgroundWorktreeRemoval({ removal, ...job }) diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index df92535fbb04..a0d9f14b1dc8 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -167,8 +167,7 @@ async function settleBackgroundWorktreeRemoval( recorded: Promise, stopSignal: AbortSignal ): Promise { - // Why: the record is the commit point, so Git starts deleting only once it is on disk. - await recorded + await waitForRecordWrite(record, recorded) let outcome: WorktreeRemovalOutcome try { if (stopSignal.aborted) { @@ -194,11 +193,39 @@ async function settleBackgroundWorktreeRemoval( } } // Why clear on failure too: the row returns live and retryable instead of retrying unseen. - if (pendingByWorktreeId.get(record.worktreeId) === record) { + const cleared = pendingByWorktreeId.get(record.worktreeId) === record + if (cleared) { pendingByWorktreeId.delete(record.worktreeId) - await persistRecords() } + // Why publish first: the clear is bookkeeping; a crash before it lands only re-runs a finish that + // re-derives what is left from Git. publishSafely(job.publish, outcome) + if (cleared) { + await persistRecords() + } +} + +// Why: the record should be on disk before Git deletes, so a quit resumes the delete, but a disk or +// file pool stall must not hold the user's delete; past this, a crash only loses the resume. +const RECORD_WRITE_WAIT_MS = 2_000 + +async function waitForRecordWrite( + record: WorktreeRemovalRecord, + recorded: Promise +): Promise { + let timer: ReturnType | undefined + const timedOut = await Promise.race([ + recorded.then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), RECORD_WRITE_WAIT_MS) + }) + ]) + clearTimeout(timer) + if (timedOut) { + console.warn( + `[worktrees] removal record for ${record.worktreePath} not on disk after ${RECORD_WRITE_WAIT_MS} ms; deleting anyway` + ) + } } function publishSafely( From ab2c4b3457386cbec1e85a8c3ce6fe99e7ce4724 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:43:04 -0700 Subject: [PATCH 18/46] test(worktrees): compare worktree paths by value and tear down with Windows lock retries Git prints forward slashes in `git worktree list` on Windows, so the real-Git removal suites never found a joined path there: positive checks failed and negative ones passed without proving anything. They now compare Git's parsed rows by value. Teardown uses the shared retrying removeTree, since Windows can hold the deleted checkout busy for a moment after Git exits. Adds a relative-path worktree case for the .git restore (skipped before Git 2.48). --- .../git/worktree-removal-real-git.test.ts | 29 +++++++----- ...interrupted-local-worktree-removal.test.ts | 44 ++++++++++++++++--- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/main/git/worktree-removal-real-git.test.ts b/src/main/git/worktree-removal-real-git.test.ts index f477f564fdde..f03554b7a1a2 100644 --- a/src/main/git/worktree-removal-real-git.test.ts +++ b/src/main/git/worktree-removal-real-git.test.ts @@ -2,13 +2,15 @@ // deregisters and refuses, or that deleting a checkout leaves Node's file pool free. import { execFile } from 'node:child_process' import { existsSync, statSync } from 'node:fs' -import { link, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises' +import { link, mkdir, mkdtemp, readFile, realpath, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { performance } from 'node:perf_hooks' import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { removeTree } from '../../shared/windows-transient-lock-removal' import { listWorktreesStrict, removeWorktree } from './worktree' +import { areWorktreePathsEqual } from './worktree-path-comparison' import { isPrunableGitFileWorktree } from '../worktree-prunable-git-file' import { removeStaleLocalWorktreeRegistration } from '../local-worktree-removal-recovery' import { sweepStaleWorktreeTrash, WORKTREE_TRASH_DIR_NAME } from '../worktree-trash' @@ -25,6 +27,13 @@ async function git(args: string[], cwd: string): Promise { return stdout } +// Why parsed: Git prints forward slashes on Windows, so raw text never contains a joined path. +async function isRegistered(path: string): Promise { + return (await listWorktreesStrict(repoPath)).some((worktree) => + areWorktreePathsEqual(worktree.path, path) + ) +} + beforeEach(async () => { // realpath: macOS hands out /var/... temp paths while Git reports /private/var/..., and Orca // matches the worktree it is removing against Git's own list. @@ -47,7 +56,7 @@ beforeEach(async () => { }) afterEach(async () => { - await rm(scratchDir, { recursive: true, force: true }) + await removeTree(scratchDir) }) describe('worktree removal against the real Git binary', () => { @@ -55,7 +64,7 @@ describe('worktree removal against the real Git binary', () => { await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) expect(existsSync(worktreePath)).toBe(false) - expect(await git(['worktree', 'list'], repoPath)).not.toContain(worktreePath) + expect(await isRegistered(worktreePath)).toBe(false) expect(existsSync(join(workspaceRoot, 'repo', WORKTREE_TRASH_DIR_NAME))).toBe(false) }) @@ -65,7 +74,7 @@ describe('worktree removal against the real Git binary', () => { await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) - expect(await git(['worktree', 'list'], repoPath)).toContain(siblingPath) + expect(await isRegistered(siblingPath)).toBe(true) expect(existsSync(siblingPath)).toBe(true) }) @@ -80,7 +89,7 @@ describe('worktree removal against the real Git binary', () => { await expect(removeWorktree(repoPath, worktreePath, false)).rejects.toThrow() expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) - expect(await git(['worktree', 'list'], repoPath)).toContain(worktreePath) + expect(await isRegistered(worktreePath)).toBe(true) }) it('does not delete a checkout through a malformed registration that names its git file', async () => { @@ -105,7 +114,9 @@ describe('worktree removal against the real Git binary', () => { const adminPath = marker.trim().replace(/^gitdir: /, '') await writeFile(join(adminPath, 'gitdir'), `${join(markerPath, '.git')}\n`) await writeFile(join(worktreePath, 'untracked.txt'), 'keep this work\n') - const row = (await listWorktreesStrict(repoPath)).find((entry) => entry.path === markerPath) + const row = (await listWorktreesStrict(repoPath)).find((entry) => + areWorktreePathsEqual(entry.path, markerPath) + ) expect(row).toBeDefined() if (!row) { throw new Error('Missing malformed registration') @@ -113,7 +124,7 @@ describe('worktree removal against the real Git binary', () => { expect(await isPrunableGitFileWorktree(row)).toBe(true) const result = await removeStaleLocalWorktreeRegistration({ - canonicalWorktreePath: markerPath, + canonicalWorktreePath: row.path, repoPath, localWorktreeGitOptions: {}, registeredWorktree: row, @@ -124,9 +135,7 @@ describe('worktree removal against the real Git binary', () => { expect(await readFile(markerPath, 'utf8')).toBe(marker) expect(await readFile(join(worktreePath, 'untracked.txt'), 'utf8')).toBe('keep this work\n') expect(await git(['rev-parse', 'refs/heads/feature'], repoPath)).toBe(`${row.head}\n`) - expect((await listWorktreesStrict(repoPath)).some((entry) => entry.path === markerPath)).toBe( - false - ) + expect(await isRegistered(markerPath)).toBe(false) expect(existsSync(adminPath)).toBe(false) }) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index 79e79f563e44..c024c0666ad7 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -2,18 +2,21 @@ // come from Git and disk, whatever point the earlier run reached. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, realpath, rm, unlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' +import { removeTree } from '../../shared/windows-transient-lock-removal' import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import type { Store } from '../persistence' import type * as HostTreeRemoval from '../host-tree-removal' import { removeHostTree } from '../host-tree-removal' import type * as WorktreeGitFileRestore from '../git/worktree-git-file-restore' import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' +import { listWorktreesStrict } from '../git/worktree' +import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, @@ -56,6 +59,13 @@ async function git(args: string[], cwd = repoPath): Promise { return stdout } +// Why parsed: Git prints forward slashes on Windows, so raw text never contains a joined path. +async function isRegistered(path: string): Promise { + return (await listWorktreesStrict(repoPath)).some((worktree) => + areWorktreePathsEqual(worktree.path, path) + ) +} + beforeEach(async () => { // realpath: macOS hands out /var/... temp paths while Git reports /private/var/.... scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-interrupted-removal-'))) @@ -77,7 +87,7 @@ beforeEach(async () => { afterEach(async () => { _resetPendingWorktreeRemovalsForTests() vi.mocked(removeHostTree).mockClear() - await rm(scratchDir, { recursive: true, force: true }) + await removeTree(scratchDir) }) async function finishAfterRestart(options: { repoGone?: boolean; head?: string } = {}): Promise<{ @@ -145,7 +155,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { outcome && 'preservedBranch' in outcome ? outcome.preservedBranch : undefined ).toBeUndefined() expect(existsSync(worktreePath)).toBe(false) - expect(await git(['worktree', 'list'])).not.toContain(worktreePath) + expect(await isRegistered(worktreePath)).toBe(false) expect(await git(['branch', '--list', 'feature'])).toBe('') expect(purged).toEqual([`repo-1::${worktreePath}`]) }) @@ -159,13 +169,33 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(outcome).toMatchObject({ status: 'removed' }) expect(existsSync(worktreePath)).toBe(false) - expect(await git(['worktree', 'list'])).not.toContain(worktreePath) + expect(await isRegistered(worktreePath)).toBe(false) expect(await git(['branch', '--list', 'feature'])).toBe('') expect(purged).toEqual([`repo-1::${worktreePath}`]) // Git's process deleted it, not Orca's. expect(removeHostTree).not.toHaveBeenCalled() }) + it('lets Git finish a relative-path checkout whose .git file it had already deleted', async (ctx) => { + await git(['worktree', 'remove', worktreePath]) + try { + await git(['worktree', 'add', '-q', '--relative-paths', worktreePath, 'feature']) + } catch { + // Git before 2.48 has no relative-path worktrees. + ctx.skip() + } + await unlink(join(worktreePath, '.git')) + await unlink(join(worktreePath, 'seed.txt')) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(existsSync(worktreePath)).toBe(false) + expect(await isRegistered(worktreePath)).toBe(false) + expect(purged).toEqual([`repo-1::${worktreePath}`]) + expect(removeHostTree).not.toHaveBeenCalled() + }) + it('deletes the leftover itself only when Git cannot be pointed back at it', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) await unlink(join(worktreePath, '.git')) @@ -176,7 +206,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(outcome).toMatchObject({ status: 'removed' }) expect(removeHostTree).toHaveBeenCalledWith(worktreePath) expect(existsSync(worktreePath)).toBe(false) - expect(await git(['worktree', 'list'])).not.toContain(worktreePath) + expect(await isRegistered(worktreePath)).toBe(false) expect(await git(['branch', '--list', 'feature'])).toBe('') expect(purged).toEqual([`repo-1::${worktreePath}`]) }) @@ -192,7 +222,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(outcome).toMatchObject({ status: 'failed' }) expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) - expect(await git(['worktree', 'list'])).toContain(worktreePath) + expect(await isRegistered(worktreePath)).toBe(true) expect(purged).toEqual([]) }) @@ -295,7 +325,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(outcome).toMatchObject({ status: 'failed' }) expect(existsSync(worktreePath)).toBe(true) - expect(await git(['worktree', 'list'])).toContain(worktreePath) + expect(await isRegistered(worktreePath)).toBe(true) expect(purged).toEqual([]) }) From 5534e950fa36c727ae743f4a00003924cd57daa5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:09:56 -0700 Subject: [PATCH 19/46] fix(worktrees): reply to a worktree delete when it has finished, not on a broadcast event A current client's delete request now waits for the host's background delete and gets its real result (removed, a preserved branch, or the error) as the reply, the way it did before the delete moved off the request. A request that arrives while the delete runs joins it and gets the same result. Every other view keeps reading the host's `removing` marker: the row leaving means the delete finished, and the row listed again without the marker shows "The delete did not finish. Try again." on a card that view had marked Deleting. A request whose reply is lost (a timeout or a dropped connection) settles the same way from a fresh listing instead of reporting a failure. Clients without the background-removal capability (mobile, the CLI, older desktops) are still answered on acceptance and have rows under removal left out of their listings. This removes the outcome on worktreesChanged and everything it needed: the renderer's outcome waiters, early-outcome buffer and TTL, per-host event-gap generations, the request pre-registration, and the accept callback bulk delete used. Bulk delete runs same-repo deletes in parallel only on this machine, whose host serializes branch cleanup per repo; SSH and paired hosts stay serialized. --- .../git/worktree-add-pending-removal.test.ts | 3 +- .../ipc/worktrees-background-removal.test.ts | 64 ++---- .../ipc/worktrees-delete-pty-teardown.test.ts | 5 +- .../ipc/worktrees-removal-recovery.test.ts | 5 - .../ipc/worktrees-test-background-removal.ts | 52 ----- src/main/ipc/worktrees-test-harness.ts | 5 +- src/main/ipc/worktrees-test-runtime-stub.ts | 19 +- src/main/ipc/worktrees-windows.test.ts | 12 +- .../register-worktree-removal-handlers.ts | 35 +-- .../remove-registered-local-worktree.ts | 10 +- ...runtime-background-removal-test-support.ts | 45 +--- .../orca-runtime-notify-ssh-state-changed.ts | 15 +- .../orca-runtime-remove-managed-worktree.ts | 24 +- ...runtime-resolve-worktree-removal-target.ts | 2 +- .../orca-runtime-test-lifecycle.spec.ts | 2 +- ...removal-and-reconciliation-part-03.spec.ts | 30 +++ .../worktree-rm-background-reply.test.ts | 61 ++++++ src/main/runtime/rpc/methods/worktree.ts | 9 +- ...worktree-removal-marker-projection.test.ts | 3 +- .../rpc/worktree-removal-marker-projection.ts | 4 +- ...interrupted-local-worktree-removal.test.ts | 31 +-- ...time-interrupted-local-worktree-removal.ts | 7 +- src/main/runtime/runtime-notifier-contract.ts | 7 +- ...ntime-registered-local-worktree-removal.ts | 13 +- .../runtime/runtime-worktree-selection.ts | 2 + src/main/window/runtime-window-lifecycle.ts | 8 +- ...orktree-background-removal-records.test.ts | 56 ++--- src/main/worktree-background-removal.test.ts | 90 ++++---- src/main/worktree-background-removal.ts | 102 ++++++--- src/preload/api/worktree-api.ts | 10 +- src/preload/api/worktrees-bridge.ts | 15 +- .../sidebar/delete-worktree-flow.test.ts | 39 ++-- .../delete-worktree-parallel-flow.test.ts | 74 +++---- .../sidebar/run-worktree-delete-with-toast.ts | 3 +- .../sidebar/worktree-delete-execution.ts | 17 +- .../sidebar/worktree-delete-request.ts | 2 - .../background-worktree-removal-bridge.ts | 66 ++---- .../ipc-events/project-catalog-ipc-bridge.ts | 10 +- .../ipc-events/runtime-client-ipc-bridge.ts | 11 - .../store/slices/worktree-removal-options.ts | 2 - .../worktrees-background-removal.test.ts | 187 +++++++--------- .../worktrees-remote-runtime-removal.test.ts | 8 +- .../teardown/background-worktree-removal.ts | 205 ------------------ .../teardown/dispatch-worktree-removal.ts | 51 +++-- .../teardown/host-worktree-removal-state.ts | 112 ++++++++++ .../worktrees/teardown/remove-worktree.ts | 2 +- .../src/web/preload-api/web-worktrees-api.ts | 24 +- src/shared/protocol-version.ts | 6 +- src/shared/runtime-client-events.ts | 3 +- .../worktree/archive-hook-removal-gate.ts | 9 + src/shared/worktree/create-types.ts | 4 +- src/shared/worktree/removal-outcome.ts | 13 -- 52 files changed, 713 insertions(+), 881 deletions(-) delete mode 100644 src/main/ipc/worktrees-test-background-removal.ts create mode 100644 src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts delete mode 100644 src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts create mode 100644 src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts delete mode 100644 src/shared/worktree/removal-outcome.ts diff --git a/src/main/git/worktree-add-pending-removal.test.ts b/src/main/git/worktree-add-pending-removal.test.ts index 08950c777d4e..fdb115979246 100644 --- a/src/main/git/worktree-add-pending-removal.test.ts +++ b/src/main/git/worktree-add-pending-removal.test.ts @@ -24,7 +24,7 @@ import { registerWorktreeSuiteHooks() function startDeleting(): void { - startBackgroundWorktreeRemoval({ + void startBackgroundWorktreeRemoval({ removal: { worktreeId: 'repo-1::/repo-feature', repoId: 'repo-1', @@ -34,7 +34,6 @@ function startDeleting(): void { force: false }, run: () => new Promise(() => {}), - catalogVersion: () => ({ epoch: 'e', sequence: 1 }), publish: () => {} }) } diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index d9b1169691f2..9cf53aa236f9 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -1,18 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { addWorktreeMock, - handleMock, listWorktreesMock, removeWorktreeMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { mockKnownFeatureWorktree } from './worktrees-test-fixtures' -import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import { - _resetPendingWorktreeRemovalsForTests, - _settlePendingWorktreeRemovalsForTests -} from '../worktree-background-removal' +import { _resetPendingWorktreeRemovalsForTests } from '../worktree-background-removal' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -96,13 +91,9 @@ vi.mock('../runtime/worktree-teardown', async () => ) vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) -type RawHandler = (event: unknown, args: unknown) => Promise> - -// The harness waits for the outcome the way the renderer does; these tests read the raw reply. -function rawRemoveHandler(): RawHandler { - const call = handleMock.mock.calls.findLast(([channel]) => channel === 'worktrees:remove') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ipcMain.handle's second argument is the registered handler. - return call?.[1] as RawHandler +function remove(args: Record): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktrees:remove resolves a RemoveWorktreeResult. + return handlers['worktrees:remove'](null, args) as Promise } type ListedRow = { id: string; removing?: true } @@ -134,23 +125,19 @@ function blockGitRemove(): { const featureId = 'repo-1::/workspace/feature-wt' describe('worktrees:remove in the background', () => { - let runtimeStub: WorktreeRuntimeStub - beforeEach(() => { - runtimeStub = setupWorktreeHandlers() + setupWorktreeHandlers() }) afterEach(() => { _resetPendingWorktreeRemovalsForTests() }) - it('accepts before Git finishes and lists the row as removing until it does', async () => { + it('replies once Git finishes, listing the row as removing until then', async () => { const worktrees = mockKnownFeatureWorktree() const git = blockGitRemove() - await expect(rawRemoveHandler()(null, { worktreeId: featureId })).resolves.toMatchObject({ - removing: true - }) + const reply = remove({ worktreeId: featureId }) await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) expect(store.removeWorktreeMeta).not.toHaveBeenCalled() @@ -160,31 +147,25 @@ describe('worktrees:remove in the background', () => { listWorktreesMock.mockResolvedValue([worktrees[0]]) git.release({ preservedBranch: { branchName: 'feature', head: 'feature' } }) - await _settlePendingWorktreeRemovalsForTests() + const result = await reply + expect(result).toMatchObject({ preservedBranch: { branchName: 'feature', head: 'feature' } }) + expect(result.removing).toBeUndefined() expect(store.removeWorktreeMeta).toHaveBeenCalledWith(featureId, 'local') - expect(runtimeStub.removalOutcomes.get(featureId)).toMatchObject({ - status: 'removed', - preservedBranch: { branchName: 'feature', head: 'feature' } - }) const after = await listRepoRows() expect(after.map((row) => row.id)).not.toContain(featureId) }) - it('publishes a failure after acceptance and lists the row normally again', async () => { + it('replies with the delete error and lists the row normally again', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) mockKnownFeatureWorktree() const git = blockGitRemove() - await rawRemoveHandler()(null, { worktreeId: featureId }) + const reply = remove({ worktreeId: featureId }) await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) git.fail(new Error('permission denied')) - await _settlePendingWorktreeRemovalsForTests() - expect(runtimeStub.removalOutcomes.get(featureId)).toMatchObject({ - status: 'failed', - error: expect.stringContaining('permission denied') - }) + await expect(reply).rejects.toThrow('permission denied') expect(store.removeWorktreeMeta).not.toHaveBeenCalled() const rows = await listRepoRows() expect(rows.find((row) => row.id === featureId)).toBeDefined() @@ -192,10 +173,7 @@ describe('worktrees:remove in the background', () => { // A retry is a fresh removal, not a join onto the failed one. removeWorktreeMock.mockResolvedValue({}) - await expect(rawRemoveHandler()(null, { worktreeId: featureId })).resolves.toMatchObject({ - removing: true - }) - await _settlePendingWorktreeRemovalsForTests() + await expect(remove({ worktreeId: featureId })).resolves.not.toHaveProperty('removing') expect(removeWorktreeMock).toHaveBeenCalledTimes(2) }) @@ -203,21 +181,21 @@ describe('worktrees:remove in the background', () => { mockKnownFeatureWorktree() const git = blockGitRemove() - await rawRemoveHandler()(null, { worktreeId: featureId }) + const first = remove({ worktreeId: featureId }) await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) - await expect( - rawRemoveHandler()(null, { worktreeId: featureId, force: true, hostId: 'local' }) - ).resolves.toMatchObject({ removing: true }) + const repeat = remove({ worktreeId: featureId, force: true, hostId: 'local' }) - git.release() - await _settlePendingWorktreeRemovalsForTests() + git.release({ preservedBranch: { branchName: 'feature', head: 'feature' } }) + const preserved = { preservedBranch: { branchName: 'feature', head: 'feature' } } + await expect(first).resolves.toMatchObject(preserved) + await expect(repeat).resolves.toMatchObject(preserved) expect(removeWorktreeMock).toHaveBeenCalledTimes(1) }) it('gives a create with the same name the next free name while the delete runs', async () => { mockKnownFeatureWorktree() blockGitRemove() - await rawRemoveHandler()(null, { worktreeId: featureId }) + void remove({ worktreeId: featureId }).catch(() => {}) await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) addWorktreeMock.mockResolvedValue({}) diff --git a/src/main/ipc/worktrees-delete-pty-teardown.test.ts b/src/main/ipc/worktrees-delete-pty-teardown.test.ts index e5ff8a2fbcae..7fec88c15ad9 100644 --- a/src/main/ipc/worktrees-delete-pty-teardown.test.ts +++ b/src/main/ipc/worktrees-delete-pty-teardown.test.ts @@ -145,11 +145,10 @@ describe('registerWorktreeHandlers', () => { ]) expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledTimes(1) - // One publication when the row starts showing as removing, one with the outcome. + // One notice when the row starts showing as removing, one after it has left the table. expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('worktrees:changed', { - repoId: 'repo-1', - removalOutcome: expect.objectContaining({ status: 'removed' }) + repoId: 'repo-1' }) }) diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index 4706a717d36a..b1e91daea9d7 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -452,11 +452,6 @@ describe('registerWorktreeHandlers', () => { expect.anything() ) expect(store.removeWorktreeMeta).not.toHaveBeenCalled() - // The only publications are the accepted delete and its failure; nothing reports it removed. - expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( - 'worktrees:changed', - expect.objectContaining({ removalOutcome: expect.objectContaining({ status: 'removed' }) }) - ) } finally { removePathSpy.mockRestore() } diff --git a/src/main/ipc/worktrees-test-background-removal.ts b/src/main/ipc/worktrees-test-background-removal.ts deleted file mode 100644 index eab750e7ba70..000000000000 --- a/src/main/ipc/worktrees-test-background-removal.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { _settlePendingWorktreeRemovalsForTests } from '../worktree-background-removal' -import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' -import { handlers } from './worktrees-test-ipc-surface' - -type Handler = (event: unknown, args: unknown) => unknown - -/** Last background-removal outcome the harness runtime published, per worktree id. */ -export const harnessRemovalOutcomes = new Map() - -function readWorktreeId(args: unknown): string | undefined { - return typeof args === 'object' && args !== null && 'worktreeId' in args - ? String(args.worktreeId) - : undefined -} - -/** - * Registers an IPC handler on the harness. `worktrees:remove` resolves when its background delete - * ends, the way the renderer's delete flow does, so tests of what the delete does keep asserting - * on its finished state and error. - */ -export function registerHarnessHandler(channel: string, handler: Handler): void { - handlers[channel] = channel === 'worktrees:remove' ? waitForOutcome(handler) : handler -} - -function waitForOutcome(handler: Handler): Handler { - return async (event, args) => { - const accepted = await handler(event, args) - const worktreeId = readWorktreeId(args) - if ( - typeof accepted !== 'object' || - accepted === null || - !('removing' in accepted) || - !worktreeId - ) { - return accepted - } - await _settlePendingWorktreeRemovalsForTests() - const outcome = harnessRemovalOutcomes.get(worktreeId) - if (!outcome) { - return accepted - } - if (outcome.status === 'failed') { - throw new Error(outcome.error) - } - const { removing: _removing, ...rest } = accepted - return { - ...rest, - ...(outcome.preservedBranch ? { preservedBranch: outcome.preservedBranch } : {}), - ...(outcome.catalogVersion ? { catalogVersion: outcome.catalogVersion } : {}) - } - } -} diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index 4ed588af9c2d..bbe2a1491610 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -10,7 +10,6 @@ import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retir import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { createWorktreeRuntimeStub, type WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { handlers, mainWindow, store } from './worktrees-test-ipc-surface' -import { registerHarnessHandler } from './worktrees-test-background-removal' import { configureMetadataPruningStoreMocks } from './worktrees-test-metadata-pruning-store' import { resetWorktreeTestSshHostHome } from '../worktree-removal-test-ssh-host-home' import { @@ -193,7 +192,9 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { delete handlers[key] } - handleMock.mockImplementation(registerHarnessHandler) + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) store.getRepos.mockReturnValue([harnessRepo]) store.getRepo.mockReturnValue({ ...harnessRepo, worktreeBaseRef: null }) diff --git a/src/main/ipc/worktrees-test-runtime-stub.ts b/src/main/ipc/worktrees-test-runtime-stub.ts index 53f4deabcdbd..68b49754f4a4 100644 --- a/src/main/ipc/worktrees-test-runtime-stub.ts +++ b/src/main/ipc/worktrees-test-runtime-stub.ts @@ -1,6 +1,4 @@ -import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import { _resetPendingWorktreeRemovalsForTests } from '../worktree-background-removal' -import { harnessRemovalOutcomes } from './worktrees-test-background-removal' import { vi } from 'vitest' export type WorktreeRuntimeStub = { @@ -22,8 +20,6 @@ export type WorktreeRuntimeStub = { acquireFileWatcherRemoval: ReturnType hydrateInferredWorktreeLineage: ReturnType publishWorktreeRemovalChange: ReturnType - /** Last background-removal outcome published per worktree id. */ - removalOutcomes: ReadonlyMap } /** Why: create-flow tests need a minimal runtime; full fetchRemoteWithCache behavior lives in fetch-remote-cache.test.ts. */ @@ -32,8 +28,6 @@ export function createWorktreeRuntimeStub(mainWindow?: { }): WorktreeRuntimeStub { // Why here: every harness setup builds a stub, so no removal from an earlier test leaks in. _resetPendingWorktreeRemovalsForTests() - const removalOutcomes = harnessRemovalOutcomes - removalOutcomes.clear() const runtimeStub: WorktreeRuntimeStub = { resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null), hasRemoteTrackingRef: vi.fn().mockResolvedValue(false), @@ -62,16 +56,9 @@ export function createWorktreeRuntimeStub(mainWindow?: { acquireFileWatcherRemoval: vi.fn(), hydrateInferredWorktreeLineage: vi.fn().mockResolvedValue(undefined), // Mirrors the real runtime notifier, which sends worktrees:changed to the window. - publishWorktreeRemovalChange: vi.fn((repoId: string, outcome?: WorktreeRemovalOutcome) => { - if (outcome) { - removalOutcomes.set(outcome.worktreeId, outcome) - } - mainWindow?.webContents.send('worktrees:changed', { - repoId, - ...(outcome ? { removalOutcome: outcome } : {}) - }) - }), - removalOutcomes + publishWorktreeRemovalChange: vi.fn((repoId: string) => { + mainWindow?.webContents.send('worktrees:changed', { repoId }) + }) } runtimeStub.acquireFileWatcherRemoval.mockImplementation( async (worktreePath: string, connectionId?: string) => { diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 8b3347233d09..596ff07be52d 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -148,7 +148,6 @@ vi.mock('./worktree-logic', async (importOriginal) => { }) import { registerWorktreeHandlers } from './worktrees' -import { _settlePendingWorktreeRemovalsForTests } from '../worktree-background-removal' import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' type HandlerMap = Record unknown> @@ -313,11 +312,8 @@ describe('registerWorktreeHandlers – Windows path handling', () => { acquireFileWatcherRemoval: vi.fn().mockResolvedValue({ finish: vi.fn().mockResolvedValue(undefined) }), - publishWorktreeRemovalChange: vi.fn((repoId: string, removalOutcome?: unknown) => - mainWindow.webContents.send('worktrees:changed', { - repoId, - ...(removalOutcome ? { removalOutcome } : {}) - }) + publishWorktreeRemovalChange: vi.fn((repoId: string) => + mainWindow.webContents.send('worktrees:changed', { repoId }) ) } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) @@ -590,7 +586,6 @@ describe('registerWorktreeHandlers – Windows path handling', () => { await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::C:/workspaces/improve-dashboard' }) - await _settlePendingWorktreeRemovalsForTests() expect(assertWorktreeCleanForRemovalMock).toHaveBeenCalledWith(registeredWorktree.path, false) expect(removeWorktreeMock).toHaveBeenCalledWith( @@ -610,8 +605,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { 'repo-1::C:/workspaces/improve-dashboard' ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { - repoId: 'repo-1', - removalOutcome: expect.objectContaining({ status: 'removed' }) + repoId: 'repo-1' }) }) it('gives the issue-command runner the same setup shell as the setup runner', () => { diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index 801fed8ac947..2d1a7a6b7784 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -12,7 +12,10 @@ import { getWorktreeRemovalOptionsKey } from './worktree-removal-coordinator' import { resolveRepoForExecutionHost } from '../repo-host-ownership' -import { isWorktreeRemovalPending } from '../../../worktree-background-removal' +import { + finishAcceptedWorktreeRemoval, + waitForPendingWorktreeRemoval +} from '../../../worktree-background-removal' export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { const { store, options, worktreeRemovalsInFlight } = context @@ -27,9 +30,10 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo } // The resolved repo supplies host ownership when legacy callers omit args.hostId. const removalHostId = getRepoExecutionHostId(repo) - // Why: a retry or a second client asking while Git still deletes joins that removal. - if (isWorktreeRemovalPending(args.worktreeId, removalHostId)) { - return { removing: true, catalogVersion: getLocalWorktreeCatalogVersion(repoId) } + // Why: a retry or a second window asking while Git still deletes joins that removal. + const pending = waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) + if (pending) { + return { ...(await pending), catalogVersion: getLocalWorktreeCatalogVersion(repoId) } } const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) const optionsKey = getWorktreeRemovalOptionsKey(args) @@ -43,7 +47,7 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. const removal = withWorktreeSpan({ stage: 'remove', path: worktreePath }, async () => { - const result = await executeWorktreeRemoval( + const accepted = await executeWorktreeRemoval( context, args, repo, @@ -51,23 +55,22 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo worktreePath, removalHostId ) + const result = await finishAcceptedWorktreeRemoval(accepted, args.worktreeId, removalHostId) + // A background job reports its own lifecycle when Git finishes. + if (!accepted.removing) { + options?.onWorktreeLifecycle?.({ + kind: 'removed', + worktreeId: args.worktreeId, + path: worktreePath + }) + } // Why stamped inside the shared promise: a coalesced second caller gets the same reply, // naming the catalog this removal produced. return { ...result, catalogVersion: getLocalWorktreeCatalogVersion(repoId) } }) worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) try { - const result = await removal - if (result.removing) { - // The background job reports its own lifecycle when Git finishes. - return result - } - options?.onWorktreeLifecycle?.({ - kind: 'removed', - worktreeId: args.worktreeId, - path: parseWorktreeId(args.worktreeId).worktreePath - }) - return result + return await removal } finally { if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { worktreeRemovalsInFlight.delete(inFlightKey) diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index a2c244b511c1..a75a2607c9e5 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -40,7 +40,6 @@ import { stopPtysForDestructiveWorktreeRemoval } from './worktree-removal-ownership' import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' -import { getLocalWorktreeCatalogVersion } from '../../../local-worktree-scan-generation' import { removesInBackground, startBackgroundWorktreeRemoval @@ -150,9 +149,9 @@ export async function removeRegisteredLocalWorktree( runtime.publishWorktreeRemovalChange(repoId) return result } - // Why background: Git deletes the checkout in its own process for 20-35 s on a large tree; every - // refusal above already ran, and clients read the host's `removing` marker until it finishes. - startBackgroundWorktreeRemoval({ + // Why detached: every refusal above already ran, and Git's 20-35 s delete must finish even when the + // request that asked for it goes away; other views read the host's `removing` marker meanwhile. + void startBackgroundWorktreeRemoval({ removal: { worktreeId: args.worktreeId, repoId, @@ -170,8 +169,7 @@ export async function removeRegisteredLocalWorktree( }) return result }, - catalogVersion: () => getLocalWorktreeCatalogVersion(repoId), - publish: (outcome) => runtime.publishWorktreeRemovalChange(repoId, outcome) + publish: () => runtime.publishWorktreeRemovalChange(repoId) }) return { removing: true } } diff --git a/src/main/runtime/orca-runtime-background-removal-test-support.ts b/src/main/runtime/orca-runtime-background-removal-test-support.ts index 3ba5e1e144f2..811c31f36fc6 100644 --- a/src/main/runtime/orca-runtime-background-removal-test-support.ts +++ b/src/main/runtime/orca-runtime-background-removal-test-support.ts @@ -1,47 +1,20 @@ import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' -import { _settlePendingWorktreeRemovalsForTests } from '../worktree-background-removal' +import type { RemoveManagedWorktreeOptions } from './runtime-worktree-selection' type RemovalRuntimePrototype = { - removeManagedWorktree: (...args: never[]) => Promise - publishWorktreeRemovalChange: (repoId: string, outcome?: WorktreeRemovalOutcome) => void + removeManagedWorktree: ( + selector: string, + options?: RemoveManagedWorktreeOptions + ) => Promise } /** - * Makes `removeManagedWorktree` resolve when its background delete ends, the way a client that - * waits for the outcome sees it, so suites about what the delete does keep asserting its end state. + * Makes `removeManagedWorktree` wait for its background delete, as a current client does, so suites + * about what the delete does keep asserting its end state. A test can still pass `false`. */ export function awaitBackgroundRemovalsInRuntimeTests(prototype: RemovalRuntimePrototype): void { const remove = prototype.removeManagedWorktree - const publish = prototype.publishWorktreeRemovalChange - const outcomes: WorktreeRemovalOutcome[] = [] - prototype.publishWorktreeRemovalChange = function (repoId, outcome) { - if (outcome) { - outcomes.push(outcome) - } - return publish.call(this, repoId, outcome) - } - prototype.removeManagedWorktree = async function (...args) { - const startedAt = outcomes.length - const accepted = await remove.apply(this, args) - if (!accepted.removing) { - return accepted - } - await _settlePendingWorktreeRemovalsForTests() - const selector = String(args[0]) - const published = outcomes.slice(startedAt) - const outcome = - published.find((candidate) => selector.includes(candidate.worktreeId)) ?? published.at(-1) - if (!outcome) { - return accepted - } - if (outcome.status === 'failed') { - throw new Error(outcome.error) - } - const { removing: _removing, ...rest } = accepted - return { - ...rest, - ...(outcome.preservedBranch ? { preservedBranch: outcome.preservedBranch } : {}) - } + prototype.removeManagedWorktree = function (selector, options = {}) { + return remove.call(this, selector, { waitForBackgroundRemoval: true, ...options }) } } diff --git a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts index 1d801154d452..b321a62f1cce 100644 --- a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts +++ b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts @@ -3,8 +3,6 @@ import { OrcaRuntimeWithGetStatus } from './orca-runtime-get-status' import type { SshConnectionState } from '../../shared/ssh-types' import { getPublicSshState } from './public-ssh-state' import { splitWorktreeId } from '../../shared/worktree/id' -import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' -import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' @@ -136,17 +134,10 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat this.emitClientEvent({ type: 'worktreesChanged', repoId }) } - /** A background removal started (no outcome) or ended; every client refetches, and the outcome - * rides the same event so the client that asked can finish its flow. */ - publishWorktreeRemovalChange(repoId: string, removalOutcome?: WorktreeRemovalOutcome): void { + /** A background removal started or ended; every client refetches and reads the `removing` marker. */ + publishWorktreeRemovalChange(repoId: string): void { this.invalidateResolvedWorktreeCache() - runWorktreeChangeInvalidators(repoId) - this.notifier?.worktreesChanged(repoId, undefined, removalOutcome) - this.emitClientEvent({ - type: 'worktreesChanged', - repoId, - ...(removalOutcome ? { removalOutcome } : {}) - }) + this.notifyWorktreesChanged(repoId) } // Why: structural catalog changes require a fresh Git scan; renderer metadata edits do not. diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index c3035f5511b9..dc065bd82248 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -6,7 +6,10 @@ import { } from '../worktree-removal-repo-owner' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' -import { isWorktreeRemovalPending } from '../worktree-background-removal' +import { + finishAcceptedWorktreeRemoval, + waitForPendingWorktreeRemoval +} from '../worktree-background-removal' import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' import { getRuntimeWorktreeRemovalOptionsKey, @@ -51,8 +54,9 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM const cleanupHostId = parseExecutionHostId(hostId)?.id const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId) // Why: a retry or a second client asking while Git still deletes joins that removal. - if (isWorktreeRemovalPending(removalTarget.id, cleanupHostId)) { - return { removing: true } + const pending = waitForPendingWorktreeRemoval(removalTarget.id, cleanupHostId) + if (pending) { + return options.waitForBackgroundRemoval ? await pending : { removing: true } } const emitRemoved = (): void => this.emitWorktreeLifecycle({ @@ -76,7 +80,9 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM optionsKey ) if (inFlightRemoval) { - return inFlightRemoval + return options.waitForBackgroundRemoval + ? finishAcceptedWorktreeRemoval(await inFlightRemoval, removalTarget.id, cleanupHostId) + : inFlightRemoval } const removal = (async (): Promise => { return withWorktreeSpan({ stage: 'remove', path: removalTarget.path }, async () => { @@ -280,18 +286,20 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM purgeRemovedWorktree() }, onRemoved: emitRemoved, - publish: (outcome) => this.publishWorktreeRemovalChange(repo.id, outcome) + publish: () => this.publishWorktreeRemovalChange(repo.id) }) }) })() this.removeManagedWorktreeInFlight.track(cleanupScopeKey, optionsKey, removal) try { - const result = await removal + const accepted = await removal // A background removal emits this when Git finishes instead. - if (!result.removing) { + if (!accepted.removing) { emitRemoved() } - return result + return options.waitForBackgroundRemoval + ? finishAcceptedWorktreeRemoval(accepted, removalTarget.id, cleanupHostId) + : accepted } finally { this.removeManagedWorktreeInFlight.release(cleanupScopeKey, removal) } diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index d9ce35224fd2..2d16002d71a4 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -58,7 +58,7 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith this.purgeRemovedWorktree(store, worktreeId, repoId, LOCAL_EXECUTION_HOST_ID), onRemoved: ({ worktreeId, worktreePath }) => this.emitWorktreeLifecycle({ kind: 'removed', worktreeId, path: worktreePath }), - publish: (repoId, outcome) => this.publishWorktreeRemovalChange(repoId, outcome) + publish: (repoId) => this.publishWorktreeRemovalChange(repoId) }) ) } diff --git a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts index a4bb0d16c222..413035af5094 100644 --- a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts +++ b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts @@ -59,7 +59,7 @@ const { sshProviderGenerations, unregisterSshGitProviderMock, updateGitHubIssueM const { updateGitHubPRDetailsMock, updateGitHubPRStateMock, updateGitHubPRTitleMock } = mocks const { updateGitLabIssueMock, updateGitLabMRMock, updateGitLabMRReviewersMock, vi } = mocks -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prototype carries both methods the removal wrapper replaces. +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prototype carries the removal method the wrapper replaces. awaitBackgroundRemovalsInRuntimeTests(mocks.OrcaRuntimeService.prototype as never) function resetRuntimeTestMocks(): void { diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index b9d14675427d..96abf6a97cef 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { resetWorktreeTestSshHostHome } from '../../worktree-removal-test-ssh-host-home' +import { _settlePendingWorktreeRemovalsForTests } from '../../worktree-background-removal' import { OrcaRuntimeService, @@ -301,6 +302,35 @@ describe('OrcaRuntimeService', () => { expect(removeWorktree).toHaveBeenCalledTimes(1) }) + it('answers a client that cannot wait as soon as Git starts deleting', async () => { + const runtime = createWorktreeRemovalRuntime() + const removeStarted = deferred() + const finishRemoval = deferred() + vi.mocked(removeWorktree).mockImplementation(async () => { + removeStarted.resolve() + await finishRemoval.promise + return {} + }) + + const accepted = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { + waitForBackgroundRemoval: false + }) + await removeStarted.promise + await expect(accepted).resolves.toEqual({ removing: true }) + finishRemoval.resolve() + await _settlePendingWorktreeRemovalsForTests() + }) + + it('replies to a waiting client with the error of a delete that fails after acceptance', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtime = createWorktreeRemovalRuntime() + vi.mocked(removeWorktree).mockRejectedValue(new Error('permission denied')) + + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toThrow( + 'permission denied' + ) + }) + it('treats forced runtime deletion of an already-missing unregistered worktree as cleanup', async () => { const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-remove-')) const missingWorktreePath = join(parentDir, 'already-deleted') diff --git a/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts b/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts new file mode 100644 index 000000000000..1905d95b2951 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import { RpcDispatcher } from '../dispatcher' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +function makeRuntime(): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktree.rm with an explicit host reads only removeManagedWorktree. + return { + getRuntimeId: () => 'test-runtime', + removeManagedWorktree: vi.fn().mockResolvedValue({}) + } as unknown as OrcaRuntimeService +} + +async function dispatchRm( + runtime: OrcaRuntimeService, + clientCapabilities: readonly RuntimeCapability[] | undefined +): Promise { + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + await dispatcher.dispatch( + { + id: 'req-1', + authToken: 'tok', + method: 'worktree.rm', + params: { worktree: 'id:wt-1', hostId: 'local' } + }, + { clientCapabilities } + ) +} + +// Why: the reply may wait out Git's delete only for a client that shows the `removing` marker; an +// older client is answered on acceptance, as its timeouts and listings expect. +describe('worktree.rm waits for the delete only for clients that can show it', () => { + it('asks the runtime to wait for a client that advertises background removal', async () => { + expect(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain( + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY + ) + const runtime = makeRuntime() + await dispatchRm(runtime, ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES) + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + 'id:wt-1', + expect.objectContaining({ waitForBackgroundRemoval: true }) + ) + }) + + it.each([ + ['a client that negotiated no capabilities', undefined], + ['a client without background removal', []] + ])('answers %s on acceptance', async (_name, capabilities) => { + const runtime = makeRuntime() + await dispatchRm(runtime, capabilities) + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + 'id:wt-1', + expect.not.objectContaining({ waitForBackgroundRemoval: true }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index c7dda776333c..468a1235a33c 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -26,6 +26,7 @@ import { WorktreeTeardownMissingTerminalsParams } from './worktree-schemas' import { WORKTREE_CATALOG_METHODS } from './worktree-catalog-methods' +import { readsWorktreeRemovalMarker } from '../worktree-removal-marker-projection' export const WORKTREE_METHODS = [ ...WORKTREE_CATALOG_METHODS, @@ -215,7 +216,8 @@ export const WORKTREE_METHODS = [ defineMethod({ name: 'worktree.rm', params: WorktreeRemove, - handler: async (params, { runtime }) => { + handler: async (params, context) => { + const { runtime } = context // Translate a paired client's runtime-local host spelling before host-qualified reads. let resolvedHostId = resolvePairedCallerHostId( () => runtime.listRepos(), @@ -250,7 +252,10 @@ export const WORKTREE_METHODS = [ runHooks: params.runHooks === true, allowUnverifiedPtyStop: params.allowUnverifiedPtyStop === true, allowFailedArchiveHook: params.allowFailedArchiveHook === true, - ...(resolvedHostId ? { hostId: resolvedHostId } : {}) + ...(resolvedHostId ? { hostId: resolvedHostId } : {}), + // Why: only a client that shows the `removing` marker can wait out Git's delete; older + // clients get the acceptance and never see the row again. + ...(readsWorktreeRemovalMarker(context) ? { waitForBackgroundRemoval: true } : {}) }) return { removed: true, diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts index 897bd326e11b..76c6916dc4a9 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts @@ -47,7 +47,7 @@ function psResult(): RuntimeWorktreePsResult { describe('worktree listings while a checkout is being deleted', () => { beforeEach(() => { - startBackgroundWorktreeRemoval({ + void startBackgroundWorktreeRemoval({ removal: { worktreeId: removingId, repoId: 'repo-1', @@ -57,7 +57,6 @@ describe('worktree listings while a checkout is being deleted', () => { force: false }, run: () => new Promise(() => {}), - catalogVersion: () => ({ epoch: 'e', sequence: 1 }), publish: () => {} }) }) diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.ts index 811ee83d9866..b3e31c1417f9 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.ts @@ -9,7 +9,9 @@ import type { RpcContext } from './core' // Why no in-process default: callers without negotiation (the CLI, host-side readers) print or act // on rows, and a checkout mid-delete is not a workspace they can use. -function readsWorktreeRemovalMarker(context: Pick): boolean { +export function readsWorktreeRemovalMarker( + context: Pick +): boolean { return ( context.clientCapabilities?.includes(WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY) === true ) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index c024c0666ad7..036e2c797a8e 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -9,7 +9,7 @@ import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' import { removeTree } from '../../shared/windows-transient-lock-removal' -import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { Store } from '../persistence' import type * as HostTreeRemoval from '../host-tree-removal' import { removeHostTree } from '../host-tree-removal' @@ -20,9 +20,9 @@ import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, - isWorktreeRemovalPending, loadWorktreeRemovalRecords, - resumeInterruptedWorktreeRemovals + resumeInterruptedWorktreeRemovals, + waitForPendingWorktreeRemoval } from '../worktree-background-removal' import { readWorktreeRemovalRecords, @@ -90,8 +90,12 @@ afterEach(async () => { await removeTree(scratchDir) }) +type FinishOutcome = + | ({ status: 'removed' } & RemoveWorktreeResult) + | { status: 'failed'; error: string } + async function finishAfterRestart(options: { repoGone?: boolean; head?: string } = {}): Promise<{ - outcome: WorktreeRemovalOutcome | undefined + outcome: FinishOutcome purged: string[] remember: ReturnType }> { @@ -108,14 +112,15 @@ async function finishAfterRestart(options: { repoGone?: boolean; head?: string } } await writeWorktreeRemovalRecords(recordsDir, () => [record]) await loadWorktreeRemovalRecords(recordsDir) - expect(isWorktreeRemovalPending(record.worktreeId)).toBe(true) + // A request that joins before the finish starts gets the finish's result. + const joined = waitForPendingWorktreeRemoval(record.worktreeId) + expect(joined).toBeDefined() const storeStub = { getRepo: (id: string) => (id === repo.id && !options.repoGone ? repo : undefined), getRepos: () => (options.repoGone ? [] : [repo]), getWorktreeMeta: () => undefined } - const outcomes: WorktreeRemovalOutcome[] = [] const purged: string[] = [] const remember = vi.fn() resumeInterruptedWorktreeRemovals((interrupted) => @@ -130,17 +135,17 @@ async function finishAfterRestart(options: { repoGone?: boolean; head?: string } }, purge: ({ worktreeId }) => purged.push(worktreeId), onRemoved: () => {}, - publish: (_repoId, outcome) => { - if (outcome) { - outcomes.push(outcome) - } - } + publish: () => {} }) ) + const outcome: FinishOutcome = await joined!.then( + (result) => ({ status: 'removed' as const, ...result }), + (error: unknown) => ({ status: 'failed' as const, error: String(error) }) + ) await _settlePendingWorktreeRemovalsForTests() expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) - expect(isWorktreeRemovalPending(record.worktreeId)).toBe(false) - return { outcome: outcomes.at(-1), purged, remember } + expect(waitForPendingWorktreeRemoval(record.worktreeId)).toBeUndefined() + return { outcome, purged, remember } } describe('finishing an interrupted worktree removal after a restart', () => { diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 83ea1602b8fc..ed73a4c9df73 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -3,10 +3,8 @@ import { join } from 'node:path' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' -import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { Store } from '../persistence' -import { getLocalWorktreeCatalogVersion } from '../local-worktree-scan-generation' import type { BackgroundWorktreeRemovalJob } from '../worktree-background-removal' import { resolveWorktreeRemovalMetadata } from '../worktree-removal-repo-owner' import type { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' @@ -33,7 +31,7 @@ type InterruptedWorktreeRemovalHost = { /** Drops the worktree's host state (metadata, history, caches), as every removal path does. */ purge: (record: WorktreeRemovalRecord) => void onRemoved: (record: WorktreeRemovalRecord) => void - publish: (repoId: string, outcome?: WorktreeRemovalOutcome) => void + publish: (repoId: string) => void } /** The background job that finishes one interrupted removal on this host. */ @@ -73,8 +71,7 @@ export function interruptedLocalWorktreeRemovalJob( host.onRemoved(record) return result }, - catalogVersion: () => getLocalWorktreeCatalogVersion(record.repoId), - publish: (outcome) => host.publish(record.repoId, outcome) + publish: () => host.publish(record.repoId) } } diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index b322823d8597..fa87d839cd5c 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -8,7 +8,6 @@ import type { WorktreeRemoteBranchConflictEvent } from '../../shared/worktree/base-ref-drift-types' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' -import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeBrowserDriverState, @@ -26,11 +25,7 @@ export type RuntimeNotifier = { selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' } reason?: 'definition' | 'run' | 'usage' }): void - worktreesChanged( - repoId: string, - renamed?: { oldWorktreeId: string; newWorktreeId: string }, - removalOutcome?: WorktreeRemovalOutcome - ): void + worktreesChanged(repoId: string, renamed?: { oldWorktreeId: string; newWorktreeId: string }): void worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void reposChanged(): void diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index b8ad001cb1d4..dd28c1a2a07a 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -26,8 +26,6 @@ import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' -import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' -import { getLocalWorktreeCatalogVersion } from '../local-worktree-scan-generation' import { removesInBackground, startBackgroundWorktreeRemoval } from '../worktree-background-removal' export async function removeRuntimeRegisteredLocalWorktree(args: { @@ -59,7 +57,7 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { ) => void /** Fired when Git finished, not on acceptance. */ onRemoved: () => void - publish: (outcome?: WorktreeRemovalOutcome) => void + publish: () => void }): Promise { const { repo, registeredWorktree, localOptions } = args const canonicalPath = registeredWorktree.path @@ -149,9 +147,9 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { args.publish() return { ...result, ...acceptedFields } } - // Why background: every refusal above already ran; Git's 20-35 s delete must not outlast client - // request timeouts, and clients read the host's `removing` marker until it finishes. - startBackgroundWorktreeRemoval({ + // Why detached: every refusal above already ran, and Git's 20-35 s delete must finish even when the + // request that asked for it times out; other views read the host's `removing` marker meanwhile. + void startBackgroundWorktreeRemoval({ removal: { worktreeId: args.target.id, repoId: repo.id, @@ -165,8 +163,7 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { args.onRemoved() return result }, - catalogVersion: () => getLocalWorktreeCatalogVersion(repo.id), - publish: (outcome) => args.publish(outcome) + publish: () => args.publish() }) return { removing: true, ...acceptedFields } } diff --git a/src/main/runtime/runtime-worktree-selection.ts b/src/main/runtime/runtime-worktree-selection.ts index 230f2952da84..2a00d01ced15 100644 --- a/src/main/runtime/runtime-worktree-selection.ts +++ b/src/main/runtime/runtime-worktree-selection.ts @@ -39,6 +39,8 @@ export type RemoveManagedWorktreeOptions = { /** Waives a FAILED archive hook (#19334). Never implied by `force`, never by `runHooks`. */ allowFailedArchiveHook?: boolean hostId?: string + /** Reply with the finished delete's result; otherwise a background delete replies on acceptance. */ + waitForBackgroundRemoval?: boolean } export function getRuntimeWorktreeRemovalOptionsKey( diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 1afcf351388b..142389099c0f 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -36,14 +36,10 @@ export function registerRuntimeWindowLifecycle( }) const send = rendererNotifications.send runtime.setNotifier({ - worktreesChanged: (repoId, renamed, removalOutcome) => { + worktreesChanged: (repoId, renamed) => { // Why: clear scan caches before the renderer handles this event, so it can't read stale TTL entries after a mutation. runWorktreeChangeInvalidators(repoId) - send('worktrees:changed', { - repoId, - ...(renamed ? { renamed } : {}), - ...(removalOutcome ? { removalOutcome } : {}) - }) + send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }) }, worktreeBaseStatus: (event) => send('worktree:baseStatus', event), worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event), diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index 04dc5903aa76..dd010c4451fd 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -2,16 +2,15 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeRemovalOutcome } from '../shared/worktree/removal-outcome' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, - isWorktreeRemovalPending, loadWorktreeRemovalRecords, projectPendingWorktreeRemovals, resumeInterruptedWorktreeRemovals, startBackgroundWorktreeRemoval, - stopBackgroundWorktreeRemovals + stopBackgroundWorktreeRemovals, + waitForPendingWorktreeRemoval } from './worktree-background-removal' import type * as WorktreeRemovalRecords from './worktree-removal-records' import { @@ -36,7 +35,7 @@ const removal = { deleteBranch: true, force: false } -const catalogVersion = { epoch: 'e', sequence: 1 } +const isPending = (): boolean => waitForPendingWorktreeRemoval(removal.worktreeId) !== undefined let directory = '' beforeEach(async () => { @@ -53,8 +52,7 @@ afterEach(async () => { /** A delete Git never finishes on its own, like one a quit or crash cuts short. */ function interruptedJob(): { run: (stopSignal: AbortSignal) => Promise - catalogVersion: () => typeof catalogVersion - publish: (outcome?: WorktreeRemovalOutcome) => void + publish: () => void started: () => boolean stopped: () => boolean } { @@ -69,7 +67,6 @@ function interruptedJob(): { reject(new Error('The operation was aborted.')) }) }), - catalogVersion: () => catalogVersion, publish: vi.fn(), started: () => started, stopped: () => stopped @@ -79,13 +76,12 @@ function interruptedJob(): { describe('durable worktree removal records', () => { it('writes the record before Git starts and clears it once the delete succeeds', async () => { let recordedAtGitStart: unknown[] = [] - startBackgroundWorktreeRemoval({ + void startBackgroundWorktreeRemoval({ removal, run: async () => { recordedAtGitStart = await readWorktreeRemovalRecords(directory) return {} }, - catalogVersion: () => catalogVersion, publish: () => {} }) await _settlePendingWorktreeRemovalsForTests() @@ -109,21 +105,20 @@ describe('durable worktree removal records', () => { it('clears the record when the delete fails so the row returns live and retryable', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const publish = vi.fn() - startBackgroundWorktreeRemoval({ + const result = startBackgroundWorktreeRemoval({ removal, run: async () => { throw new Error('Permission denied') }, - catalogVersion: () => catalogVersion, publish }) + await expect(result).rejects.toThrow('Permission denied') await _settlePendingWorktreeRemovalsForTests() expect(await readWorktreeRemovalRecords(directory)).toEqual([]) - expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) - expect(publish).toHaveBeenLastCalledWith( - expect.objectContaining({ status: 'failed', error: 'Permission denied' }) - ) + expect(isPending()).toBe(false) + // Once on acceptance, once after the row left the table. + expect(publish).toHaveBeenCalledTimes(2) }) it('starts Git after a bounded wait when the record write stalls', async () => { @@ -131,10 +126,9 @@ describe('durable worktree removal records', () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) vi.mocked(writeWorktreeRemovalRecords).mockReturnValueOnce(new Promise(() => {})) const run = vi.fn(async () => ({})) - startBackgroundWorktreeRemoval({ + void startBackgroundWorktreeRemoval({ removal, run, - catalogVersion: () => catalogVersion, publish: () => {} }) @@ -146,10 +140,10 @@ describe('durable worktree removal records', () => { await _settlePendingWorktreeRemovalsForTests() }) - it('publishes the outcome without waiting for the record to be cleared on disk', async () => { + it('replies without waiting for the record to be cleared on disk', async () => { let finishClear = (): void => {} const publish = vi.fn() - startBackgroundWorktreeRemoval({ + const result = startBackgroundWorktreeRemoval({ removal, run: async () => { vi.mocked(writeWorktreeRemovalRecords).mockReturnValueOnce( @@ -159,20 +153,18 @@ describe('durable worktree removal records', () => { ) return {} }, - catalogVersion: () => catalogVersion, publish }) - await vi.waitFor(() => - expect(publish).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'removed' })) - ) + await expect(result).resolves.toEqual({}) + expect(publish).toHaveBeenCalledTimes(2) finishClear() await _settlePendingWorktreeRemovalsForTests() }) it('stops Git on quit without waiting and keeps the record for the next start', async () => { const job = interruptedJob() - startBackgroundWorktreeRemoval({ removal, ...job }) + void startBackgroundWorktreeRemoval({ removal, ...job }) await vi.waitFor(() => expect(job.started()).toBe(true)) expect(stopBackgroundWorktreeRemovals()).toBeUndefined() @@ -186,15 +178,16 @@ describe('durable worktree removal records', () => { it('marks the row as removing after a restart and finishes it with the same job', async () => { const job = interruptedJob() - startBackgroundWorktreeRemoval({ removal, ...job }) + void startBackgroundWorktreeRemoval({ removal, ...job }) await vi.waitFor(() => expect(job.started()).toBe(true)) stopBackgroundWorktreeRemovals() await _settlePendingWorktreeRemovalsForTests() // Restart: nothing in memory, only the file. _resetPendingWorktreeRemovalsForTests() - expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) + expect(isPending()).toBe(false) await loadWorktreeRemovalRecords(directory) + const joined = waitForPendingWorktreeRemoval(removal.worktreeId) const rows: { id: string; hostId?: undefined }[] = [ { id: removal.worktreeId }, { id: 'repo-1::/work/other' } @@ -214,21 +207,20 @@ describe('durable worktree removal records', () => { resumed.push(`${record.worktreePath} ${record.branch} ${record.deleteBranch}`) return {} }, - catalogVersion: () => catalogVersion, publish })) await _settlePendingWorktreeRemovalsForTests() expect(resumed).toEqual(['/work/feature feature true']) - expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) + expect(isPending()).toBe(false) expect(await readWorktreeRemovalRecords(directory)).toEqual([]) - expect(publish).toHaveBeenCalledWith( - expect.objectContaining({ worktreeId: removal.worktreeId, status: 'removed' }) - ) + // A request that joined after the restart gets the resumed delete's result. + await expect(joined).resolves.toEqual({}) + expect(publish).toHaveBeenCalledTimes(1) }) it('does not start a second job for a removal that is still running', async () => { - startBackgroundWorktreeRemoval({ removal, ...interruptedJob() }) + void startBackgroundWorktreeRemoval({ removal, ...interruptedJob() }) const jobFor = vi.fn() resumeInterruptedWorktreeRemovals(jobFor) expect(jobFor).not.toHaveBeenCalled() diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts index 4876e6b2b619..96c24775e0bb 100644 --- a/src/main/worktree-background-removal.test.ts +++ b/src/main/worktree-background-removal.test.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeRemovalOutcome } from '../shared/worktree/removal-outcome' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, assertNoPendingWorktreeRemovalConflict, - isWorktreeRemovalPending, + finishAcceptedWorktreeRemoval, projectPendingWorktreeRemovals, removesInBackground, - startBackgroundWorktreeRemoval + startBackgroundWorktreeRemoval, + waitForPendingWorktreeRemoval } from './worktree-background-removal' const removal = { @@ -18,7 +18,8 @@ const removal = { deleteBranch: true, force: false } -const catalogVersion = { epoch: 'e', sequence: 7 } +const isPending = (hostId?: string): boolean => + waitForPendingWorktreeRemoval(removal.worktreeId, hostId) !== undefined function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void @@ -33,77 +34,78 @@ describe('background worktree removal', () => { _resetPendingWorktreeRemovalsForTests() }) - it('accepts before Git finishes and publishes the outcome after the row leaves the table', async () => { + it('resolves with the delete result once Git finishes, after the row has left the table', async () => { const git = deferred<{ preservedBranch: { branchName: string; head: string } }>() - const published: { outcome?: WorktreeRemovalOutcome; pendingAtPublish: boolean }[] = [] - startBackgroundWorktreeRemoval({ + const pendingAtPublish: boolean[] = [] + const result = startBackgroundWorktreeRemoval({ removal, run: () => git.promise, - catalogVersion: () => catalogVersion, - publish: (outcome) => - published.push({ outcome, pendingAtPublish: isWorktreeRemovalPending(removal.worktreeId) }) + publish: () => pendingAtPublish.push(isPending()) }) - expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(true) - expect(published).toEqual([{ outcome: undefined, pendingAtPublish: true }]) + expect(isPending()).toBe(true) + expect(pendingAtPublish).toEqual([true]) git.resolve({ preservedBranch: { branchName: 'feature', head: 'abc' } }) + await expect(result).resolves.toEqual({ + preservedBranch: { branchName: 'feature', head: 'abc' } + }) await _settlePendingWorktreeRemovalsForTests() + expect(isPending()).toBe(false) + // A refetch the end notice triggers must not see the row as still removing. + expect(pendingAtPublish).toEqual([true, false]) + }) - expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) - // A refetch the outcome triggers must not see the row as still removing. - expect(published[1]).toEqual({ - outcome: { - worktreeId: removal.worktreeId, - status: 'removed', - preservedBranch: { branchName: 'feature', head: 'abc' }, - catalogVersion - }, - pendingAtPublish: false + it('gives a request that joins a running delete that delete’s result', async () => { + const git = deferred<{ preservedBranch: { branchName: string; head: string } }>() + const run = vi.fn(() => git.promise) + void startBackgroundWorktreeRemoval({ removal, run, publish: () => {} }) + + const joined = finishAcceptedWorktreeRemoval( + { removing: true, warning: 'hook skipped' }, + removal.worktreeId + ) + git.resolve({ preservedBranch: { branchName: 'feature', head: 'abc' } }) + + await expect(joined).resolves.toEqual({ + warning: 'hook skipped', + preservedBranch: { branchName: 'feature', head: 'abc' } }) + expect(run).toHaveBeenCalledTimes(1) }) - it('publishes a failure and clears the row so a retry starts over', async () => { + it('rejects with the delete error and clears the row so a retry starts over', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) - const publish = vi.fn() - startBackgroundWorktreeRemoval({ + const result = startBackgroundWorktreeRemoval({ removal, run: async () => { throw new Error('Failed to delete worktree at /work/feature. Permission denied') }, - catalogVersion: () => catalogVersion, - publish + publish: () => {} }) + await expect(result).rejects.toThrow('Permission denied') await _settlePendingWorktreeRemovalsForTests() - - expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) - expect(publish).toHaveBeenLastCalledWith({ - worktreeId: removal.worktreeId, - status: 'failed', - error: 'Failed to delete worktree at /work/feature. Permission denied' - }) + expect(isPending()).toBe(false) }) it('keeps the table consistent when publishing throws', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) - startBackgroundWorktreeRemoval({ + void startBackgroundWorktreeRemoval({ removal, run: async () => ({}), - catalogVersion: () => catalogVersion, publish: () => { throw new Error('window gone') } }) await _settlePendingWorktreeRemovalsForTests() - expect(isWorktreeRemovalPending(removal.worktreeId)).toBe(false) + expect(isPending()).toBe(false) }) it('refuses a create at the same path or branch while Git deletes', async () => { const git = deferred>() - startBackgroundWorktreeRemoval({ + void startBackgroundWorktreeRemoval({ removal, run: () => git.promise, - catalogVersion: () => catalogVersion, publish: () => {} }) @@ -131,21 +133,19 @@ describe('background worktree removal', () => { }) it('answers only for this host: a same-id row on an SSH host is not being removed here', () => { - startBackgroundWorktreeRemoval({ + void startBackgroundWorktreeRemoval({ removal, run: () => new Promise(() => {}), - catalogVersion: () => catalogVersion, publish: () => {} }) - expect(isWorktreeRemovalPending(removal.worktreeId, 'local')).toBe(true) - expect(isWorktreeRemovalPending(removal.worktreeId, 'ssh:box')).toBe(false) + expect(isPending('local')).toBe(true) + expect(isPending('ssh:box')).toBe(false) }) it('marks rows for clients that read the marker and omits them for clients that do not', () => { - startBackgroundWorktreeRemoval({ + void startBackgroundWorktreeRemoval({ removal, run: () => new Promise(() => {}), - catalogVersion: () => catalogVersion, publish: () => {} }) const rows = [ diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index a0d9f14b1dc8..f49771f90fcd 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -1,7 +1,5 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' import type { RemoveWorktreeResult } from '../shared/worktree/create-types' -import type { WorktreeCatalogVersion } from '../shared/worktree/catalog-version' -import type { WorktreeRemovalOutcome } from '../shared/worktree/removal-outcome' import type { GitWorktreeInfo } from '../shared/worktree/types' import { normalizeLocalBranchRef } from './git/worktree-operation-options' import { areWorktreePathsEqual } from './git/worktree-path-comparison' @@ -15,13 +13,21 @@ import { export type BackgroundWorktreeRemovalJob = { /** `stopSignal` aborts on an orderly quit; pass it only to the checkout delete. */ run: (stopSignal: AbortSignal) => Promise - catalogVersion: () => WorktreeCatalogVersion - publish: (outcome?: WorktreeRemovalOutcome) => void + /** Fires when the row starts showing as removing, and again after it has left the table. */ + publish: () => void +} + +type RemovalSettlement = { + result: Promise + resolve: (result: RemoveWorktreeResult) => void + reject: (error: unknown) => void } // The accepted removals, mirrored to disk on every change; listings and joins read only this. const pendingByWorktreeId = new Map() const jobsByWorktreeId = new Map>() +// What every request for a pending removal waits on: the first one and any that join it. +const settlementsByWorktreeId = new Map() const stopControllers = new Set() let recordsDirectory: string | null = null @@ -30,11 +36,26 @@ export async function loadWorktreeRemovalRecords(directory: string): Promise((settle, fail) => { + resolve = settle + reject = fail + }) + // Why: a removal nobody waits on (an older client's, or one a restart resumed) may still fail. + result.catch(() => {}) + const settlement = { result, resolve, reject } + pendingByWorktreeId.set(record.worktreeId, record) + settlementsByWorktreeId.set(record.worktreeId, settlement) + return settlement +} + function persistRecords(): Promise { if (!recordsDirectory) { return Promise.resolve() @@ -47,11 +68,28 @@ function persistRecords(): Promise { }) } -/** Only this host's local checkouts are removed in the background. */ -export function isWorktreeRemovalPending(worktreeId: string, hostId?: ExecutionHostId): boolean { +/** + * The result of the removal this host is running for the worktree, for a request that joins it. + * Only this host's local checkouts are removed in the background. + */ +export function waitForPendingWorktreeRemoval( + worktreeId: string, + hostId?: ExecutionHostId +): Promise | undefined { return (hostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID - ? pendingByWorktreeId.has(worktreeId) - : false + ? settlementsByWorktreeId.get(worktreeId)?.result + : undefined +} + +/** Waits for the delete an accepted background removal started; the acceptance's fields ride along. */ +export async function finishAcceptedWorktreeRemoval( + accepted: T, + worktreeId: string, + hostId?: ExecutionHostId +): Promise> { + const { removing, ...acceptance } = accepted + const pending = removing ? waitForPendingWorktreeRemoval(worktreeId, hostId) : undefined + return pending ? { ...acceptance, ...(await pending) } : acceptance } /** WSL checkouts still delete inline, as before; moving them off the request is a follow-up. */ @@ -98,8 +136,9 @@ export function assertNoPendingWorktreeRemovalConflict( } /** - * Records an accepted removal and runs its delete off the request. `publish` fires once when the - * row starts showing as removing, and once with the outcome after the row has left the table. + * Records an accepted removal and runs its delete detached from the request that asked for it, so + * the delete finishes even when that request times out or its client goes away. Resolves with the + * delete's result. */ export function startBackgroundWorktreeRemoval( args: { @@ -108,7 +147,7 @@ export function startBackgroundWorktreeRemoval( 'worktreeId' | 'repoId' | 'repoPath' | 'deleteBranch' | 'force' > & { worktree: Pick } } & BackgroundWorktreeRemovalJob -): void { +): Promise { const { worktree, ...accepted } = args.removal const record: WorktreeRemovalRecord = { ...accepted, @@ -117,9 +156,10 @@ export function startBackgroundWorktreeRemoval( head: worktree.head, requestedAt: Date.now() } - pendingByWorktreeId.set(record.worktreeId, record) + const settlement = addPendingRemoval(record) runBackgroundWorktreeRemoval(record, args, persistRecords()) - publishSafely(args.publish, undefined) + publishSafely(args.publish) + return settlement.result } /** Runs the same delete again for every record a quit or crash left without a running job. */ @@ -168,38 +208,34 @@ async function settleBackgroundWorktreeRemoval( stopSignal: AbortSignal ): Promise { await waitForRecordWrite(record, recorded) - let outcome: WorktreeRemovalOutcome + let settle: (settlement: RemovalSettlement) => void try { if (stopSignal.aborted) { return } const result = await job.run(stopSignal) - outcome = { - worktreeId: record.worktreeId, - status: 'removed', - ...(result.preservedBranch ? { preservedBranch: result.preservedBranch } : {}), - catalogVersion: job.catalogVersion() - } + settle = (settlement) => settlement.resolve(result) } catch (error) { if (stopSignal.aborted) { // Why: quit stopped Git; the record stays so the next start finishes this delete. return } console.warn(`[worktrees] background removal of ${record.worktreePath} failed`, error) - outcome = { - worktreeId: record.worktreeId, - status: 'failed', - error: error instanceof Error ? error.message : String(error) - } + settle = (settlement) => settlement.reject(error) } // Why clear on failure too: the row returns live and retryable instead of retrying unseen. const cleared = pendingByWorktreeId.get(record.worktreeId) === record if (cleared) { pendingByWorktreeId.delete(record.worktreeId) + const settlement = settlementsByWorktreeId.get(record.worktreeId) + settlementsByWorktreeId.delete(record.worktreeId) + if (settlement) { + settle(settlement) + } } - // Why publish first: the clear is bookkeeping; a crash before it lands only re-runs a finish that - // re-derives what is left from Git. - publishSafely(job.publish, outcome) + // Why notify before the clear is on disk: the clear is bookkeeping; a crash before it lands only + // re-runs a finish that re-derives what is left from Git. + publishSafely(job.publish) if (cleared) { await persistRecords() } @@ -228,12 +264,9 @@ async function waitForRecordWrite( } } -function publishSafely( - publish: (outcome?: WorktreeRemovalOutcome) => void, - outcome: WorktreeRemovalOutcome | undefined -): void { +function publishSafely(publish: () => void): void { try { - publish(outcome) + publish() } catch (error) { // Why: a failed notification must not strand the table entry or reject the detached job. console.error('[worktrees] failed to publish background removal state', error) @@ -273,6 +306,7 @@ export async function _settlePendingWorktreeRemovalsForTests(): Promise { export function _resetPendingWorktreeRemovalsForTests(): void { pendingByWorktreeId.clear() jobsByWorktreeId.clear() + settlementsByWorktreeId.clear() stopControllers.clear() recordsDirectory = null } diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index b0aa2ab9236a..115ee66d9a29 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -9,7 +9,6 @@ import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' import type { ExecutionHostId } from '../../shared/execution-host' -import type { WorktreeRemovalOutcome } from '../../shared/worktree/removal-outcome' import type { RetiredNameRegistry } from '../../shared/worktree/retired-name-registry' import type { FolderWorkspacePathStatus, @@ -135,14 +134,7 @@ export type WorktreeApi = { /** Full CLI output of the last branch auto-rename generation failure, held * in main memory only — null after a restart or once the failure clears. */ getBranchRenameFailureOutput: (args: { worktreeId: string }) => Promise - onChanged: ( - callback: (data: { - repoId: string - renamed?: { oldWorktreeId: string; newWorktreeId: string } - /** Set when a background removal on this host ended. */ - removalOutcome?: WorktreeRemovalOutcome - }) => void - ) => () => void + onChanged: (callback: (data: { repoId: string }) => void) => () => void onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void) => () => void onHeadIdentitiesChanged: ( callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index 1bc7f935ba7d..8fb8d5728ac9 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -10,8 +10,6 @@ import type { import type { WorktreeHeadIdentity } from '../../shared/worktree/types' import type { PreloadApi } from '../api-types' -type WorktreesChangedEvent = Parameters[0]>[0] - export const worktreesApi = { list: (args) => ipcRenderer.invoke('worktrees:list', args), listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args), @@ -70,9 +68,16 @@ export const worktreesApi = { getBranchRenameFailureOutput: (args) => ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args), - onChanged: (callback: (data: WorktreesChangedEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: WorktreesChangedEvent) => - callback(data) + onChanged: ( + callback: (data: { + repoId: string + renamed?: { oldWorktreeId: string; newWorktreeId: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { repoId: string; renamed?: { oldWorktreeId: string; newWorktreeId: string } } + ) => callback(data) ipcRenderer.on('worktrees:changed', listener) return () => ipcRenderer.removeListener('worktrees:changed', listener) }, diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index 06424efd904b..d050d1c076aa 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -230,9 +230,15 @@ describe('delete worktree flow', () => { }) it('revalidates each queued instance immediately before execution', async () => { + // Same-repo deletes queue on a host that does not serialize their branch cleanup itself. setWorktrees([ - { id: 'wt-1', instanceId: 'instance-1', path: '/workspaces/first-longer' }, - { id: 'wt-2', instanceId: 'instance-2', path: '/workspaces/second' } + { + id: 'wt-1', + instanceId: 'instance-1', + path: '/workspaces/first-longer', + hostId: 'ssh:builder' + }, + { id: 'wt-2', instanceId: 'instance-2', path: '/workspaces/second', hostId: 'ssh:builder' } ]) const targets = Array.from(mocks.state.worktreeMap.values()) let finishFirst!: (result: { ok: true }) => void @@ -243,27 +249,35 @@ describe('delete worktree flow', () => { const deletion = runWorktreeDeletesInParallel(targets) await vi.waitFor(() => expect(mocks.state.removeWorktree).toHaveBeenCalledWith( - { id: 'wt-1', executionHostId: null }, + { id: 'wt-1', executionHostId: 'ssh:builder' }, false, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) ) setWorktrees([ - { id: 'wt-1', instanceId: 'instance-1', path: '/workspaces/first-longer' }, - { id: 'wt-2', instanceId: 'replacement-instance', path: '/workspaces/second' } + { + id: 'wt-1', + instanceId: 'instance-1', + path: '/workspaces/first-longer', + hostId: 'ssh:builder' + }, + { + id: 'wt-2', + instanceId: 'replacement-instance', + path: '/workspaces/second', + hostId: 'ssh:builder' + } ]) finishFirst({ ok: true }) - await expect(deletion).resolves.toEqual([{ id: 'wt-1', executionHostId: null }]) + await expect(deletion).resolves.toEqual([{ id: 'wt-1', executionHostId: 'ssh:builder' }]) expect(mocks.state.removeWorktree).not.toHaveBeenCalledWith( - { id: 'wt-2', executionHostId: null }, + { id: 'wt-2', executionHostId: 'ssh:builder' }, false, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) expect(toast.info).toHaveBeenCalledWith( @@ -308,8 +322,7 @@ describe('delete worktree flow', () => { expect(mocks.state.openModal).not.toHaveBeenCalled() expect(mocks.state.removeWorktree).toHaveBeenCalledWith( { id: 'wt-1', executionHostId: null }, - false, - { onAccepted: expect.any(Function) } + false ) await vi.waitFor(() => { expect(onDeleted).toHaveBeenCalledWith([{ id: 'wt-1', executionHostId: null }]) diff --git a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts index 4836e15c86f3..daa0df4e96d3 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts @@ -154,8 +154,7 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'wt-1', executionHostId: null }, false, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith( @@ -163,8 +162,7 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'wt-2', executionHostId: null }, false, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) expect(mocks.state.markWorktreesDeleting).toHaveBeenCalledWith(['wt-1', 'wt-2']) @@ -205,8 +203,7 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'child', executionHostId: null }, false, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) @@ -221,20 +218,14 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'parent', executionHostId: null }, false, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) }) - it('starts the next same-repo delete once the host accepts the previous one', async () => { + it('starts same-repo deletes on this machine without waiting for earlier ones to finish', async () => { const first = deferredDeleteResult() - mocks.state.removeWorktree.mockImplementationOnce( - (_target: unknown, _force: unknown, options?: { onAccepted?: () => void }) => { - options?.onAccepted?.() - return first.promise - } - ) + mocks.state.removeWorktree.mockImplementationOnce(() => first.promise) const deleted = runDeletesForCurrentWorktrees([ { id: 'wt-1', displayName: 'one', repoId: 'repo-a', path: '/workspaces/one' }, @@ -246,20 +237,35 @@ describe('runWorktreeDeletesInParallel', () => { await expect(deleted).resolves.toHaveLength(2) }) - it('keeps a parent delete waiting for an accepted child to finish', async () => { + it('runs same-repo deletes one at a time on a host that does not serialize them itself', async () => { + const first = deferredDeleteResult() + mocks.state.removeWorktree.mockImplementationOnce(() => first.promise) + const hostId = 'ssh:builder' as const + + const deleted = runDeletesForCurrentWorktrees([ + { id: 'wt-1', displayName: 'one', repoId: 'repo-a', path: '/workspaces/one', hostId }, + { id: 'wt-2', displayName: 'two', repoId: 'repo-a', path: '/workspaces/two', hostId } + ]) + + await vi.waitFor(() => expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1)) + await Promise.resolve() + await Promise.resolve() + expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1) + first.resolve({ ok: true }) + await expect(deleted).resolves.toHaveLength(2) + expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(2) + }) + + it('keeps a parent delete waiting for its child to finish on this machine', async () => { const child = deferredDeleteResult() - mocks.state.removeWorktree.mockImplementationOnce( - (_target: unknown, _force: unknown, options?: { onAccepted?: () => void }) => { - options?.onAccepted?.() - return child.promise - } - ) + mocks.state.removeWorktree.mockImplementationOnce(() => child.promise) const deleted = runDeletesForCurrentWorktrees([ { id: 'parent', displayName: 'parent', repoId: 'repo-a', path: '/workspaces/parent' }, { id: 'child', displayName: 'child', repoId: 'repo-a', path: '/workspaces/parent/child' } ]) + await vi.waitFor(() => expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1)) await Promise.resolve() await Promise.resolve() expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1) @@ -279,8 +285,7 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'child', executionHostId: null }, false, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith( @@ -288,8 +293,7 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'parent', executionHostId: null }, false, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) }) @@ -308,8 +312,7 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'wt-1', executionHostId: null }, true, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith( @@ -317,8 +320,7 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'wt-2', executionHostId: null }, true, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) }) @@ -342,8 +344,7 @@ describe('runWorktreeDeletesInParallel', () => { expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1) expect(mocks.state.removeWorktree).toHaveBeenCalledWith( { id: 'wt-1', executionHostId: null }, - false, - { onAccepted: expect.any(Function) } + false ) expect(toast.error).not.toHaveBeenCalled() }) @@ -378,13 +379,13 @@ describe('runWorktreeDeletesInParallel', () => { 1, { id: 'shared', executionHostId: 'local' }, false, - { suppressPreservedBranchToast: true, onAccepted: expect.any(Function) } + { suppressPreservedBranchToast: true } ) expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith( 2, { id: 'shared', executionHostId: 'ssh:builder' }, false, - { suppressPreservedBranchToast: true, onAccepted: expect.any(Function) } + { suppressPreservedBranchToast: true } ) }) @@ -413,8 +414,7 @@ describe('runWorktreeDeletesInParallel', () => { { id: 'child', executionHostId: null }, false, { - suppressPreservedBranchToast: true, - onAccepted: expect.any(Function) + suppressPreservedBranchToast: true } ) expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('parent') @@ -451,7 +451,7 @@ describe('runWorktreeDeletesInParallel', () => { expect(mocks.state.removeWorktree).toHaveBeenCalledWith( { id: hostBParent.id, executionHostId: hostBParent.hostId }, false, - { suppressPreservedBranchToast: true, onAccepted: expect.any(Function) } + { suppressPreservedBranchToast: true } ) }) diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts index 9c39a2328252..82e3a1ed0481 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts @@ -36,8 +36,7 @@ export function runWorktreeDeleteWithToast( const removeOptions = { ...(options.suppressPreservedBranchToast ? { suppressPreservedBranchToast: true } : {}), - ...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {}), - ...(options.onAccepted ? { onAccepted: options.onAccepted } : {}) + ...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {}) } const showFailureToast = ( error: string, diff --git a/src/renderer/src/components/sidebar/worktree-delete-execution.ts b/src/renderer/src/components/sidebar/worktree-delete-execution.ts index 87a3f40453f1..5cd784ff6545 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-execution.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-execution.ts @@ -5,6 +5,7 @@ import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import type { Worktree } from '../../../../shared/worktree/types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { composeWorktreeHostIdentity, getWorktreeHostIdentity @@ -109,6 +110,10 @@ export async function runWorktreeDeletesInParallel( const deletedInGroup: WorktreeRemovalTarget[] = [] const failedInGroup: (typeof group)[number][] = [] const started: { path: string; settled: Promise }[] = [] + // Why only this machine's repos run in parallel: its host serializes the branch cleanup per + // repo and limits concurrent deletes, while SSH and paired (possibly older) hosts race the + // repo's ref locks when one repo deletes in parallel (#2259). + const serialized = (group[0]?.hostId ?? LOCAL_EXECUTION_HOST_ID) !== LOCAL_EXECUTION_HOST_ID for (const target of group) { // A descendant's outcome decides whether its ancestor may be deleted at all. const descendants = started.filter((earlier) => @@ -117,10 +122,6 @@ export async function runWorktreeDeletesInParallel( if (descendants.length > 0) { await Promise.all(descendants.map((earlier) => earlier.settled)) } - let markAccepted: () => void = () => {} - const accepted = new Promise((resolve) => { - markAccepted = resolve - }) const settled = runInWorktreeDeleteTurn(target.id, async () => { // A queued target may be recreated while an earlier repo sibling is deleting. // Why by host (STA-4343): the id-keyed map keeps ONE row per `repoId::path`, @@ -146,7 +147,6 @@ export async function runWorktreeDeletesInParallel( { ...options, focusSuccessorOnDelete: false, - onAccepted: markAccepted, suppressPreservedBranchToast: aggregatePreservedBranches, ...(snapshotPruneBatch ? { snapshotPruneBatchId: snapshotPruneBatch.batchId } : {}), onPreservedBranch: (branch) => { @@ -162,11 +162,10 @@ export async function runWorktreeDeletesInParallel( failedInGroup.push(target) } }) - void settled.catch(() => {}) started.push({ path: target.path, settled }) - // Why: same-repo deletes queue only until the host accepts; it serializes their ref - // cleanup itself, and Git's 20-35 s checkout delete must not run one repo sibling at a time. - await Promise.race([accepted, settled]) + if (serialized) { + await settled + } } await Promise.all(started.map((entry) => entry.settled)) return deletedInGroup diff --git a/src/renderer/src/components/sidebar/worktree-delete-request.ts b/src/renderer/src/components/sidebar/worktree-delete-request.ts index 5ce69a91052e..b13b9c072c19 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-request.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-request.ts @@ -28,8 +28,6 @@ export type WorktreeDeleteWithToastOptions = { snapshotPruneBatchId?: string // Batch deletion commits one focus handoff after all targets settle. focusSuccessorOnDelete?: boolean - /** See `RemoveWorktreeOptions.onAccepted`. */ - onAccepted?: () => void } export function toWorktreeDeleteIdentities( diff --git a/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts b/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts index 111d31f18374..ca219bdd03ea 100644 --- a/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts @@ -1,17 +1,11 @@ -import type { ExecutionHostId } from '../../../../shared/execution-host' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' -import type { WorktreeRemovalOutcome } from '../../../../shared/worktree/removal-outcome' import type { Worktree } from '../../../../shared/worktree/types' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import { getDeleteStateForWorktreeHost } from '@/components/sidebar/worktree-delete-state-host-match' import { useAppStore } from '../../store' -import type { AppState } from '../../store/types' import { - rowHostId, - setBackgroundWorktreeRemovalRowsLookup, - settleBackgroundWorktreeRemoval, - settleBackgroundWorktreeRemovalsFromRows -} from '../../store/slices/worktrees/teardown/background-worktree-removal' + UNFINISHED_WORKTREE_REMOVAL_ERROR, + settleHostWorktreeRemovals +} from '../../store/slices/worktrees/teardown/host-worktree-removal-state' type HostMarkedRow = Pick type AppStoreApi = Pick @@ -24,28 +18,20 @@ function deleteStateKey(row: HostMarkedRow): string { return row.hostId ? getWorktreeHostIdentity(row) : row.id } -function rowsForWorktree(state: AppState, worktreeId: string): Worktree[] { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return [ - ...(state.worktreesByRepo[repoId] ?? []), - ...(state.detectedWorktreesByRepo[repoId]?.worktrees ?? []) - ] -} - -/** Keeps the existing Deleting card state set while the host lists a row as removing. */ +/** + * Shows the existing Deleting card while the host lists a row as removing, for views that did not + * ask for the delete. The row leaving means it finished; the row returning unmarked means it did not. + */ export function reconcileHostWorktreeRemovals(store: AppStoreApi = useAppStore): void { + settleHostWorktreeRemovals() const state = store.getState() - settleBackgroundWorktreeRemovalsFromRows((worktreeId) => rowsForWorktree(state, worktreeId)) const marked: HostMarkedRow[] = [] - const seen = new Set() + const listed = new Map() for (const rows of Object.values(state.worktreesByRepo)) { for (const row of rows) { - if (!row.removing) { - continue - } const key = deleteStateKey(row) - seen.add(key) - if (hostMarkedDeleteStates.has(key)) { + listed.set(key, row) + if (!row.removing || hostMarkedDeleteStates.has(key)) { continue } const current = getDeleteStateForWorktreeHost(row, state.deleteStateByWorktreeId) @@ -60,37 +46,25 @@ export function reconcileHostWorktreeRemovals(store: AppStoreApi = useAppStore): state.markWorktreesDeleting(marked) } for (const [key, row] of hostMarkedDeleteStates) { - if (seen.has(key)) { + const listedRow = listed.get(key) + if (listedRow?.removing) { continue } hostMarkedDeleteStates.delete(key) - if (store.getState().deleteStateByWorktreeId[key]?.isDeleting) { - store.getState().clearWorktreeDeleteState(row.id, row.hostId) + if (!store.getState().deleteStateByWorktreeId[key]?.isDeleting) { + continue } - } -} - -/** Routes a host's removal outcome to the delete that asked for it, or onto a host-marked card. */ -export function applyBackgroundWorktreeRemovalOutcome( - hostId: ExecutionHostId, - outcome: WorktreeRemovalOutcome, - store: AppStoreApi = useAppStore -): void { - if (settleBackgroundWorktreeRemoval(hostId, outcome) || outcome.status !== 'failed') { - return - } - for (const [key, row] of hostMarkedDeleteStates) { - if (row.id !== outcome.worktreeId || rowHostId(row) !== hostId) { + if (!listedRow) { + store.getState().clearWorktreeDeleteState(row.id, row.hostId) continue } - hostMarkedDeleteStates.delete(key) store.setState((s) => ({ deleteStateByWorktreeId: { ...s.deleteStateByWorktreeId, [key]: { isDeleting: false, ...(row.hostId ? { executionHostId: row.hostId } : {}), - error: outcome.error, + error: UNFINISHED_WORKTREE_REMOVAL_ERROR, canForceDelete: false, forceDeleteReason: null } @@ -100,10 +74,6 @@ export function applyBackgroundWorktreeRemovalOutcome( } export function registerBackgroundWorktreeRemovalBridge(unsubs: (() => void)[]): void { - setBackgroundWorktreeRemovalRowsLookup((worktreeId) => - rowsForWorktree(useAppStore.getState(), worktreeId) - ) - unsubs.push(() => setBackgroundWorktreeRemovalRowsLookup(null)) let previousRows = useAppStore.getState().worktreesByRepo let previousDetected = useAppStore.getState().detectedWorktreesByRepo unsubs.push( diff --git a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts index 6e0e38fcfe0a..6c75e473725d 100644 --- a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts @@ -1,6 +1,3 @@ -import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import type { WorktreeRemovalOutcome } from '../../../../shared/worktree/removal-outcome' -import { applyBackgroundWorktreeRemovalOutcome } from './background-worktree-removal-bridge' import { applyWorktreeHeadIdentities } from '../worktree-head-identity-apply' import type { WorktreeChangeRefreshQueue } from '../worktree-change-refresh-queue' import { useAppStore } from '../../store' @@ -36,18 +33,13 @@ export function registerProjectCatalogIpcBridge( async (data: { repoId: string renamed?: { oldWorktreeId: string; newWorktreeId: string } - removalOutcome?: WorktreeRemovalOutcome }) => { - const { removalOutcome, ...change } = data - if (removalOutcome) { - applyBackgroundWorktreeRemovalOutcome(LOCAL_EXECUTION_HOST_ID, removalOutcome) - } // Why: preserve this event's local origin across queue delays and runtime // focus changes; otherwise an unbound repo can refresh from the wrong host. // A folder rename changes the worktree id; handleWorktreesChanged re-keys // state and shields it from the deletion diff. worktreeChangeRefreshQueue.enqueue({ - ...change, + ...data, forceLocalOwner: true }) } diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index be7e354a87c1..ae014d702300 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -28,8 +28,6 @@ import { invalidateRuntimeClientEventReplay } from './runtime-environment-subscription-selection' import type { WorktreeEventRuntime } from './worktree-event-runtime' -import { applyBackgroundWorktreeRemovalOutcome } from './background-worktree-removal-bridge' -import { noteBackgroundWorktreeRemovalEventGap } from '../../store/slices/worktrees/teardown/background-worktree-removal' export function registerRuntimeClientIpcBridge( unsubs: (() => void)[], @@ -112,12 +110,6 @@ export function registerRuntimeClientIpcBridge( return } if (event.type === 'worktreesChanged') { - if (event.removalOutcome) { - applyBackgroundWorktreeRemovalOutcome( - toRuntimeExecutionHostId(environmentId), - event.removalOutcome - ) - } void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() => worktreeChangeRefreshQueue.enqueue({ repoId: event.repoId, @@ -146,8 +138,6 @@ export function registerRuntimeClientIpcBridge( getDesiredEnvironmentIds: () => getRuntimeClientEventEnvironmentIds(useAppStore.getState()), getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]), subscribe: (environmentId, onEvent, onError, isCurrent) => { - // Why: events emitted before this subscription existed were not delivered to it. - noteBackgroundWorktreeRemovalEventGap(toRuntimeExecutionHostId(environmentId)) const sshGeneration = getEnvironmentSshStateGeneration(environmentId) const runtimeGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) const runtimeRevision = getRuntimeEnvironmentRevision(environmentId) @@ -167,7 +157,6 @@ export function registerRuntimeClientIpcBridge( if (!isCurrent()) { return } - noteBackgroundWorktreeRemovalEventGap(toRuntimeExecutionHostId(environmentId)) invalidateRuntimeClientEventReplay({ getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, refreshRuntimeStatus: () => { diff --git a/src/renderer/src/store/slices/worktree-removal-options.ts b/src/renderer/src/store/slices/worktree-removal-options.ts index 04779f64e2c6..c77568248dec 100644 --- a/src/renderer/src/store/slices/worktree-removal-options.ts +++ b/src/renderer/src/store/slices/worktree-removal-options.ts @@ -17,6 +17,4 @@ export type RemoveWorktreeOptions = { sameIdSurvivingHostId?: ExecutionHostId /** Both scan owners are in this cleanup batch, so neither is a survivor. */ ignoreWorkspaceCleanupScanSurvivors?: boolean - /** Fires when the host has accepted the delete; its checkout may still be deleting. */ - onAccepted?: () => void } diff --git a/src/renderer/src/store/slices/worktrees-background-removal.test.ts b/src/renderer/src/store/slices/worktrees-background-removal.test.ts index f9a4516018da..2b8d1d8394e1 100644 --- a/src/renderer/src/store/slices/worktrees-background-removal.test.ts +++ b/src/renderer/src/store/slices/worktrees-background-removal.test.ts @@ -3,15 +3,9 @@ import { toast } from 'sonner' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -import { - _resetBackgroundWorktreeRemovalsForTests, - noteBackgroundWorktreeRemovalEventGap, - setBackgroundWorktreeRemovalRowsLookup, - settleBackgroundWorktreeRemoval -} from './worktrees/teardown/background-worktree-removal' +import { _resetHostWorktreeRemovalsForTests } from './worktrees/teardown/host-worktree-removal-state' import { _resetBackgroundWorktreeRemovalBridgeForTests, - applyBackgroundWorktreeRemovalOutcome, reconcileHostWorktreeRemovals } from '../../hooks/ipc-events/background-worktree-removal-bridge' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' @@ -59,35 +53,56 @@ describe('removing a worktree the host deletes in the background', () => { vi.clearAllMocks() clearRuntimeCompatibilityCacheForTests() store = createTestStore() - setBackgroundWorktreeRemovalRowsLookup((id) => - (store.getState().worktreesByRepo.repo1 ?? []).filter((row) => row.id === id) - ) - mockApi.worktrees.remove.mockResolvedValue({ - removing: true, - catalogVersion: { epoch: 'e', sequence: 1 } - }) }) afterEach(() => { - _resetBackgroundWorktreeRemovalsForTests() + _resetHostWorktreeRemovalsForTests() _resetBackgroundWorktreeRemovalBridgeForTests() }) - it('keeps the card Deleting until the host reports the delete finished', async () => { + function deferredRemoval(): { + resolve: (value: unknown) => void + reject: (error: Error) => void + } { + const handle = { resolve: (_value: unknown) => {}, reject: (_error: Error) => {} } + mockApi.worktrees.remove.mockImplementation( + () => + new Promise((resolve, reject) => { + handle.resolve = resolve + handle.reject = reject + }) + ) + return handle + } + + /** Stands in for the listing refresh; `rows` is what the host lists once it lands. */ + function hostListsOnRefresh(rows: 'removed' | 'removing' | 'unmarked' | Error) { + const refresh = vi.fn() + refresh.mockImplementation(async () => { + if (rows instanceof Error) { + throw rows + } + if (rows === 'removed') { + seedStore(store, { worktreesByRepo: { repo1: [] } }) + } else { + seedRow(store, rows === 'removing' ? { removing: true } : {}) + } + return true + }) + store.setState({ fetchWorktrees: refresh }) + return refresh + } + + it('keeps the card Deleting until the host replies that the delete finished', async () => { seedRow(store) + const host = deferredRemoval() const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) - await Promise.resolve() expect(deleteState(store)?.isDeleting).toBe(true) expect(store.getState().worktreesByRepo.repo1?.map((row) => row.id)).toEqual([worktreeId]) - settleBackgroundWorktreeRemoval('local', { - worktreeId, - status: 'removed', - preservedBranch: { branchName: 'feature', head: 'abc123' } - }) - + host.resolve({ preservedBranch: { branchName: 'feature', head: 'abc123' } }) await expect(removal).resolves.toMatchObject({ ok: true, preservedBranch: { branchName: 'feature', head: 'abc123' } @@ -97,20 +112,17 @@ describe('removing a worktree the host deletes in the background', () => { expect(toast.warning).toHaveBeenCalledTimes(1) }) - it('shows the host error on the card when the delete fails after acceptance', async () => { + it('shows the host error from the reply on the card', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) seedRow(store) - const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) - await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) - await Promise.resolve() - - settleBackgroundWorktreeRemoval('local', { - worktreeId, - status: 'failed', - error: 'Failed to delete worktree at /path/wt1. Permission denied' - }) + const refresh = hostListsOnRefresh('unmarked') + mockApi.worktrees.remove.mockRejectedValue( + new Error('Failed to delete worktree at /path/wt1. Permission denied') + ) - await expect(removal).resolves.toEqual({ + await expect( + store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) + ).resolves.toEqual({ ok: false, error: 'Failed to delete worktree at /path/wt1. Permission denied' }) @@ -118,64 +130,55 @@ describe('removing a worktree the host deletes in the background', () => { isDeleting: false, error: 'Failed to delete worktree at /path/wt1. Permission denied' }) - expect(store.getState().worktreesByRepo.repo1?.map((row) => row.id)).toEqual([worktreeId]) + // A host refusal is an answer, not a lost reply. + expect(refresh).not.toHaveBeenCalled() }) - it('waits for the outcome when Git unlists the row before the host finishes the removal', async () => { + it('finishes a delete whose reply timed out once the host listing drops the row', async () => { seedRow(store) + const refresh = hostListsOnRefresh('removing') + mockApi.worktrees.remove.mockRejectedValue(new Error('Request timed out: worktree.rm')) const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) - await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) + + await vi.waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)) await Promise.resolve() + expect(deleteState(store)).toMatchObject({ isDeleting: true, error: null }) - // The worktree-directory watcher refetches as soon as Git drops the registration, while the - // host is still deleting the branch. - seedRow(store, { removing: true }) - reconcileHostWorktreeRemovals(store) seedStore(store, { worktreesByRepo: { repo1: [] } }) reconcileHostWorktreeRemovals(store) - expect(deleteState(store)?.isDeleting).toBe(true) - - settleBackgroundWorktreeRemoval('local', { - worktreeId, - status: 'removed', - preservedBranch: { branchName: 'feature', head: 'abc123' } - }) - await expect(removal).resolves.toMatchObject({ - ok: true, - preservedBranch: { branchName: 'feature', head: 'abc123' } - }) - expect(toast.warning).toHaveBeenCalledTimes(1) + await expect(removal).resolves.toEqual({ ok: true }) }) - it('finishes from the listing when the event stream had a gap', async () => { - seedRow(store) - const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) - await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) - await Promise.resolve() - + it('reports a delete whose reply was lost and that the host lists without the marker', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) seedRow(store, { removing: true }) - reconcileHostWorktreeRemovals(store) - seedStore(store, { worktreesByRepo: { repo1: [] } }) - reconcileHostWorktreeRemovals(store) - noteBackgroundWorktreeRemovalEventGap('local') + hostListsOnRefresh('unmarked') + mockApi.worktrees.remove.mockRejectedValue(new Error('Remote Orca runtime connection closed.')) - await expect(removal).resolves.toEqual({ ok: true }) + await expect( + store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) + ).resolves.toEqual({ ok: false, error: 'The delete did not finish. Try again.' }) }) - it('reports a delete the host dropped once a gap shows the row back unmarked', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) + it('treats a row the refreshed listing no longer has as deleted', async () => { seedRow(store) - const removal = store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) - await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) - await Promise.resolve() + hostListsOnRefresh('removed') + mockApi.worktrees.remove.mockRejectedValue(new Error('Request timed out: worktree.rm')) - noteBackgroundWorktreeRemovalEventGap('local') - seedRow(store, { removing: true }) - reconcileHostWorktreeRemovals(store) + await expect( + store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) + ).resolves.toEqual({ ok: true }) + }) + + it('reports the lost reply when the host listing cannot be read either', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) seedRow(store) - reconcileHostWorktreeRemovals(store) + hostListsOnRefresh(new Error('Remote Orca runtime is not connected.')) + mockApi.worktrees.remove.mockRejectedValue(new Error('Request timed out: worktree.rm')) - await expect(removal).resolves.toMatchObject({ ok: false }) + await expect( + store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) + ).resolves.toEqual({ ok: false, error: 'Request timed out: worktree.rm' }) }) it('shows Deleting on a client that did not start the delete, from the host marker alone', () => { @@ -183,44 +186,22 @@ describe('removing a worktree the host deletes in the background', () => { reconcileHostWorktreeRemovals(store) expect(deleteState(store)).toMatchObject({ isDeleting: true, phase: 'deleting' }) - // Git finished or the host dropped the marker: the card returns to normal. - seedRow(store) + // Git finished: the row leaves the listing. + seedStore(store, { worktreesByRepo: { repo1: [] } }) reconcileHostWorktreeRemovals(store) expect(deleteState(store)).toBeUndefined() }) - it('puts a failure the host publishes on a card it marked Deleting', () => { + it('shows the existing card error when a row it marked Deleting comes back unmarked', () => { seedRow(store, { removing: true }) reconcileHostWorktreeRemovals(store) - applyBackgroundWorktreeRemovalOutcome( - 'local', - { worktreeId, status: 'failed', error: 'Permission denied' }, - store - ) - - expect(deleteState(store)).toMatchObject({ isDeleting: false, error: 'Permission denied' }) - }) - - it('does not fail a retry with the failure of an earlier delete it did not wait on', async () => { - seedRow(store, { removing: true }) - reconcileHostWorktreeRemovals(store) - applyBackgroundWorktreeRemovalOutcome( - 'local', - { worktreeId, status: 'failed', error: 'Permission denied' }, - store - ) seedRow(store) reconcileHostWorktreeRemovals(store) - - const retry = store.getState().removeWorktree({ id: worktreeId, executionHostId: 'local' }) - await vi.waitFor(() => expect(mockApi.worktrees.remove).toHaveBeenCalled()) - await Promise.resolve() - await Promise.resolve() - expect(deleteState(store)).toMatchObject({ isDeleting: true, error: null }) - - settleBackgroundWorktreeRemoval('local', { worktreeId, status: 'removed' }) - await expect(retry).resolves.toEqual({ ok: true }) + expect(deleteState(store)).toMatchObject({ + isDeleting: false, + error: 'The delete did not finish. Try again.' + }) }) it('leaves a delete this renderer started to that flow', () => { diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts index dc54b8c7763e..0bbe857ef6a6 100644 --- a/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts +++ b/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ARCHIVE_HOOK_TIMEOUT_MS } from '../../../../shared/worktree/archive-hook-removal-gate' +import { worktreeRemovalReplyTimeoutMs } from '../../../../shared/worktree/archive-hook-removal-gate' import type { AppState } from '../types' import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { makeWorktree } from './worktrees-slice-test-fixtures' @@ -66,7 +66,7 @@ describe('worktree remote runtime mutations', () => { runHooks: true }, // Hooks run here, so the client must outlast the host's archive-hook budget (#19334). - timeoutMs: ARCHIVE_HOOK_TIMEOUT_MS + 60_000, + timeoutMs: worktreeRemovalReplyTimeoutMs(true), expectedEnvironmentPairingRevision: undefined, expectedRuntimeId: undefined }) @@ -130,7 +130,7 @@ describe('worktree remote runtime mutations', () => { runHooks: true }, // Hooks run here, so the client must outlast the host's archive-hook budget (#19334). - timeoutMs: ARCHIVE_HOOK_TIMEOUT_MS + 60_000, + timeoutMs: worktreeRemovalReplyTimeoutMs(true), expectedEnvironmentPairingRevision: undefined, expectedRuntimeId: undefined }) @@ -229,7 +229,7 @@ describe('worktree remote runtime mutations', () => { runHooks: true }, // Hooks run here, so the client must outlast the host's archive-hook budget (#19334). - timeoutMs: ARCHIVE_HOOK_TIMEOUT_MS + 60_000 + timeoutMs: worktreeRemovalReplyTimeoutMs(true) }) expect(mockApi.worktrees.remove).not.toHaveBeenCalled() expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([]) diff --git a/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts deleted file mode 100644 index a7916b79baaf..000000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/background-worktree-removal.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - LOCAL_EXECUTION_HOST_ID, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' -import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types' -import type { WorktreeRemovalOutcome } from '../../../../../../shared/worktree/removal-outcome' -import type { Worktree } from '../../../../../../shared/worktree/types' -import type { RuntimeClientTarget } from '../../../../runtime/runtime-client-target' - -type Waiter = { - worktreeId: string - hostId: ExecutionHostId - accepted: RemoveWorktreeResult - knownToRenderer: boolean - observedRemoving: boolean - /** Set once this host's event stream had a gap, so the outcome event may never arrive. */ - eventsMayBeLost: boolean - resolve: (result: RemoveWorktreeResult) => void - reject: (error: Error) => void -} - -// Why: on a runtime connection the outcome event and the acceptance reply travel separately, so a -// fast failure can land first; keep it long enough for the waiter that is about to register. -const EARLY_OUTCOME_TTL_MS = 60_000 - -type RemovalRow = Pick -type RowsLookup = (worktreeId: string) => readonly RemovalRow[] - -const waiters = new Map() -// Why injected: this slice cannot import the store; the app bridge that watches listings supplies it. -let rowsLookup: RowsLookup | null = null -const earlyOutcomes = new Map() -const eventGapGenerationByHost = new Map() - -function removalKey(hostId: ExecutionHostId, worktreeId: string): string { - return `${hostId}\0${worktreeId}` -} - -/** The host id this renderer files rows from `target` under, which is what outcomes are keyed by. */ -export function backgroundRemovalHostId(target: RuntimeClientTarget): ExecutionHostId { - return target.kind === 'local' - ? LOCAL_EXECUTION_HOST_ID - : toRuntimeExecutionHostId(target.environmentId) -} - -export function rowHostId(row: Pick): ExecutionHostId { - return row.hostId ?? LOCAL_EXECUTION_HOST_ID -} - -export function setBackgroundWorktreeRemovalRowsLookup(lookup: RowsLookup | null): void { - rowsLookup = lookup -} - -function findRow( - rows: readonly RemovalRow[], - hostId: ExecutionHostId, - worktreeId: string -): RemovalRow | undefined { - return rows.find((row) => row.id === worktreeId && rowHostId(row) === hostId) -} - -function finishedResult( - accepted: RemoveWorktreeResult, - outcome: WorktreeRemovalOutcome -): RemoveWorktreeResult { - if (outcome.status === 'failed') { - throw new Error(outcome.error) - } - const { removing: _removing, catalogVersion: _acceptedVersion, ...rest } = accepted - return { - ...rest, - ...(outcome.preservedBranch ? { preservedBranch: outcome.preservedBranch } : {}), - ...(outcome.catalogVersion ? { catalogVersion: outcome.catalogVersion } : {}) - } -} - -/** - * Call just before sending a removal request. Drops any outcome buffered for this workspace (it - * belongs to an earlier removal, never to this request) and returns the host's event-gap - * generation for `waitForBackgroundWorktreeRemoval`. - */ -export function beginBackgroundWorktreeRemovalRequest( - hostId: ExecutionHostId, - worktreeId: string -): number { - earlyOutcomes.delete(removalKey(hostId, worktreeId)) - return eventGapGenerationByHost.get(hostId) ?? 0 -} - -/** - * Records that events from this host may have been dropped (a reconnect or a new subscription). - * Only then may listings settle a waiting removal, since its outcome event may never come. - */ -export function noteBackgroundWorktreeRemovalEventGap(hostId: ExecutionHostId): void { - eventGapGenerationByHost.set(hostId, (eventGapGenerationByHost.get(hostId) ?? 0) + 1) - for (const waiter of waiters.values()) { - if (waiter.hostId === hostId) { - waiter.eventsMayBeLost = true - } - } - if (rowsLookup) { - settleBackgroundWorktreeRemovalsFromRows(rowsLookup) - } -} - -/** - * Resolves when the host finishes a removal it accepted in the background, with the result an - * inline removal would have returned, or rejects with the host's error. - */ -export function waitForBackgroundWorktreeRemoval(args: { - hostId: ExecutionHostId - worktreeId: string - accepted: RemoveWorktreeResult - /** From `beginBackgroundWorktreeRemovalRequest`; a gap since then may have dropped the outcome. */ - eventGapGeneration: number -}): Promise { - const key = removalKey(args.hostId, args.worktreeId) - const early = earlyOutcomes.get(key) - earlyOutcomes.delete(key) - if (early && early.expiresAt > Date.now()) { - try { - return Promise.resolve(finishedResult(args.accepted, early.outcome)) - } catch (error) { - return Promise.reject(error) - } - } - return new Promise((resolve, reject) => { - waiters.get(key)?.reject(new Error('Superseded by a newer delete of this workspace.')) - waiters.set(key, { - worktreeId: args.worktreeId, - hostId: args.hostId, - accepted: args.accepted, - // Only a row this renderer listed can prove the delete finished by disappearing. - knownToRenderer: - findRow(rowsLookup?.(args.worktreeId) ?? [], args.hostId, args.worktreeId) !== undefined, - observedRemoving: false, - eventsMayBeLost: (eventGapGenerationByHost.get(args.hostId) ?? 0) !== args.eventGapGeneration, - resolve, - reject - }) - }) -} - -/** Returns false when no removal in this renderer was waiting on the outcome. */ -export function settleBackgroundWorktreeRemoval( - hostId: ExecutionHostId, - outcome: WorktreeRemovalOutcome -): boolean { - const key = removalKey(hostId, outcome.worktreeId) - const waiter = waiters.get(key) - if (!waiter) { - const now = Date.now() - for (const [earlyKey, early] of earlyOutcomes) { - if (early.expiresAt <= now) { - earlyOutcomes.delete(earlyKey) - } - } - earlyOutcomes.set(key, { outcome, expiresAt: now + EARLY_OUTCOME_TTL_MS }) - return false - } - waiters.delete(key) - try { - waiter.resolve(finishedResult(waiter.accepted, outcome)) - } catch (error) { - waiter.reject(error instanceof Error ? error : new Error(String(error))) - } - return true -} - -/** - * Settles waiters from listings when the outcome event may have been lost: a row seen removing - * that then vanishes finished, one that returns unmarked did not. - */ -export function settleBackgroundWorktreeRemovalsFromRows(rowsFor: RowsLookup): void { - for (const [key, waiter] of waiters) { - // Why: Git unlists the row before the branch delete and metadata purge finish, so while the - // event stream is intact only the outcome carries the preserved branch or a late failure. - if (!waiter.eventsMayBeLost) { - continue - } - const row = findRow(rowsFor(waiter.worktreeId), waiter.hostId, waiter.worktreeId) - if (row?.removing) { - waiter.observedRemoving = true - continue - } - if (!row && (waiter.observedRemoving || waiter.knownToRenderer)) { - waiters.delete(key) - const { removing: _removing, catalogVersion: _acceptedVersion, ...rest } = waiter.accepted - waiter.resolve(rest) - continue - } - if (row && waiter.observedRemoving) { - waiters.delete(key) - waiter.reject(new Error('The delete did not finish. Try again.')) - } - } -} - -export function _resetBackgroundWorktreeRemovalsForTests(): void { - waiters.clear() - earlyOutcomes.clear() - eventGapGenerationByHost.clear() - rowsLookup = null -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts index 203900cab5d2..fa77425092de 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts @@ -3,12 +3,12 @@ import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/cre import { callRuntimeRpc, type getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' import type { RemoveWorktreeOptions } from '../../worktree-removal-options' -import { ARCHIVE_HOOK_TIMEOUT_MS } from '../../../../../../shared/worktree/archive-hook-removal-gate' +import type { WorktreeSliceGet } from '../listing/worktree-slice-types' import { - backgroundRemovalHostId, - beginBackgroundWorktreeRemovalRequest, - waitForBackgroundWorktreeRemoval -} from './background-worktree-removal' + isWorktreeRemovalReplyLost, + settleLostWorktreeRemovalReply +} from './host-worktree-removal-state' +import { worktreeRemovalReplyTimeoutMs } from '../../../../../../shared/worktree/archive-hook-removal-gate' /** * Sends the destructive removal over whichever transport owns this workspace. @@ -22,31 +22,33 @@ export async function dispatchWorktreeRemoval(args: { hostId: ExecutionHostId | undefined force: boolean | undefined skipArchive: boolean - forgetLocalOnly: boolean + get: WorktreeSliceGet target: ReturnType options: RemoveWorktreeOptions | undefined /** Re-checks mid-flight ownership immediately before the destructive call. */ assertCurrent: () => void }): Promise { - const removalHostId = backgroundRemovalHostId(args.target) - const eventGapGeneration = beginBackgroundWorktreeRemovalRequest(removalHostId, args.worktreeId) - const accepted = await requestWorktreeRemoval(args) - args.options?.onAccepted?.() - // The card stays Deleting while the host's Git finishes; its outcome settles this. - return accepted?.removing - ? waitForBackgroundWorktreeRemoval({ - hostId: removalHostId, - eventGapGeneration, - worktreeId: args.worktreeId, - accepted - }) - : accepted + try { + return await requestWorktreeRemoval(args) + } catch (error) { + if (args.options?.mode === 'forget-local' || !isWorktreeRemovalReplyLost(error)) { + throw error + } + // Why: the host may still be deleting; its listing answers what the lost reply would have. + await settleLostWorktreeRemovalReply(args.get, { + worktreeId: args.worktreeId, + hostId: args.hostId, + replyError: error + }) + return {} + } } async function requestWorktreeRemoval( args: Parameters[0] ): Promise { - const { worktreeId, hostId, force, skipArchive, forgetLocalOnly, target, options } = args + const { worktreeId, hostId, force, skipArchive, target, options } = args + const forgetLocalOnly = options?.mode === 'forget-local' const snapshotPruneBatch = options?.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {} @@ -81,12 +83,9 @@ async function requestWorktreeRemoval( runHooks: !skipArchive }, { - // Why not a flat 60s (#19334): the host may run an archive hook for up to - // ARCHIVE_HOOK_TIMEOUT_MS before it decides anything. A client that gives up first reports a - // failure for a removal that is still in progress — and if the hook then succeeds, the host - // deletes the checkout while the user has been told the delete failed. Outlast the hook when - // one can run; keep the short budget when none will. - timeoutMs: skipArchive ? 60_000 : ARCHIVE_HOOK_TIMEOUT_MS + 60_000 + // Why (#19334): the host may run an archive hook before it decides anything, then waits for + // Git's delete before it replies; outlast both when a hook can run. + timeoutMs: worktreeRemovalReplyTimeoutMs(!skipArchive) } ) } diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts b/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts new file mode 100644 index 000000000000..e89fa82ecbb5 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts @@ -0,0 +1,112 @@ +import { + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../../../../shared/execution-host' +import { + isRecoverableRemoteRuntimeConnectionError, + isRuntimeRpcQueueOverloadError, + toRemoteRuntimeClientErrorLike +} from '../../../../../../shared/remote-runtime-client-error-classification' +import { getRepoIdFromWorktreeId } from '../../../../../../shared/worktree/id' +import type { Worktree } from '../../../../../../shared/worktree/types' +import type { WorktreeSliceGet } from '../listing/worktree-slice-types' + +export const UNFINISHED_WORKTREE_REMOVAL_ERROR = 'The delete did not finish. Try again.' + +type RemovalRow = Pick + +function rowHostId(row: Pick): ExecutionHostId { + return row.hostId ?? LOCAL_EXECUTION_HOST_ID +} + +/** + * A delete request that failed in transport (a timeout or a dropped connection) says nothing about + * the delete: the host may have accepted it and still be deleting. A queue overload never sent it. + */ +export function isWorktreeRemovalReplyLost(error: unknown): boolean { + const reported = toRemoteRuntimeClientErrorLike(error) + if (isRuntimeRpcQueueOverloadError(reported)) { + return false + } + const message = reported.message.toLowerCase() + return ( + isRecoverableRemoteRuntimeConnectionError(reported) || + message.includes('request timed out') || + message.includes('connection interrupted') + ) +} + +const pendingJudgements = new Set<() => void>() + +/** + * Settles a delete whose reply was lost from the host's listing, as every other view does: the row + * leaving means the delete finished, and the row listed without `removing` means it did not. When + * the listing cannot be read either, rejects with the lost reply's error. + */ +function waitForHostWorktreeRemoval(args: { + hostId: ExecutionHostId | undefined + worktreeId: string + readRows: () => readonly RemovalRow[] + /** Re-reads the host's listing; true only when an authoritative listing was applied. */ + refresh: () => Promise + replyError: unknown +}): Promise { + const hostId = args.hostId ?? LOCAL_EXECUTION_HOST_ID + return new Promise((resolve, reject) => { + const judge = (): void => { + const row = args + .readRows() + .find((candidate) => candidate.id === args.worktreeId && rowHostId(candidate) === hostId) + if (row?.removing) { + return + } + pendingJudgements.delete(judge) + if (row) { + reject(new Error(UNFINISHED_WORKTREE_REMOVAL_ERROR)) + } else { + resolve() + } + } + // Why judge only after a refresh: the rows held when the reply was lost can predate the host + // accepting the delete, and would read as a delete that never started. + const fail = (): void => reject(args.replyError) + args.refresh().then((applied) => { + if (!applied) { + fail() + return + } + pendingJudgements.add(judge) + judge() + }, fail) + }) +} + +/** A lost reply for a delete of `worktreeId`, settled from its repo's listing on `hostId`. */ +export function settleLostWorktreeRemovalReply( + get: WorktreeSliceGet, + args: { worktreeId: string; hostId: ExecutionHostId | undefined; replyError: unknown } +): Promise { + const repoId = getRepoIdFromWorktreeId(args.worktreeId) + return waitForHostWorktreeRemoval({ + ...args, + readRows: () => [ + ...(get().worktreesByRepo[repoId] ?? []), + ...(get().detectedWorktreesByRepo[repoId]?.worktrees ?? []) + ], + refresh: () => + args.hostId + ? get().fetchWorktrees(repoId, { executionHostId: args.hostId }) + : get().fetchWorktrees(repoId) + }) +} + +/** Re-reads every delete waiting on the host's listing; call when listings change. */ +export function settleHostWorktreeRemovals(): void { + for (const judge of pendingJudgements) { + judge() + } +} + +export function _resetHostWorktreeRemovalsForTests(): void { + pendingJudgements.clear() +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index b8174b207cbd..314e778648c5 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -135,7 +135,7 @@ export function createRemoveWorktree( hostId, force, skipArchive, - forgetLocalOnly, + get, target, options, assertCurrent: () => removalGenerationGuard?.assertCurrent() diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts index 2e11b258fed2..ee626b187547 100644 --- a/src/renderer/src/web/preload-api/web-worktrees-api.ts +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -7,6 +7,7 @@ import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/workt import { readRetiredNameRegistryForRepo } from '../../../../shared/worktree/retired-name-cache' import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' import type { Worktree } from '../../../../shared/worktree/types' +import { worktreeRemovalReplyTimeoutMs } from '../../../../shared/worktree/archive-hook-removal-gate' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { callRuntimeResult, @@ -129,15 +130,20 @@ export function createWorktreesApi(): NonNullable['worktrees }), remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => { invalidateRuntimeWorktreeCaches() - return callRuntimeResult('worktree.rm', { - worktree: toRuntimeWorktreeSelector(worktreeId), - ...(hostId ? { hostId } : {}), - force, - // Why (#11960): the web client renders the same Force Delete affordances, so - // dropping this field here would leave paired clients permanently wedged. - allowUnverifiedPtyStop, - runHooks: skipArchive !== true - }) + return callRuntimeResult( + 'worktree.rm', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + ...(hostId ? { hostId } : {}), + force, + // Why (#11960): the web client renders the same Force Delete affordances, so + // dropping this field here would leave paired clients permanently wedged. + allowUnverifiedPtyStop, + runHooks: skipArchive !== true + }, + // Why: the host replies once Git has deleted the checkout, past the default 30 s budget. + worktreeRemovalReplyTimeoutMs(skipArchive !== true) + ) }, // Why: forget-locally clears a desktop workspace pinned to a dead SSH host; a paired web client has no such ghost state. forgetLocal: () => { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 5adb40417d9c..8ed9e0f8d1f6 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -262,9 +262,9 @@ export const WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-defaults.v1' as const export const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-source-defaults.v1' as const -// Why: the host answers worktree.rm on acceptance and deletes in the background while Git still -// lists the checkout. A client that cannot read `removing` would re-show that row as a normal -// workspace, so the host leaves rows under removal out of listings for clients without this. +// Why: Git's checkout delete outlives worktree.rm's older client timeouts. A client with this waits +// for the reply and shows `removing` rows as Deleting; one without it is answered on acceptance and +// would re-show that row as a normal workspace, so the host leaves such rows out of its listings. export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY = 'worktree.background-removal.v1' as const // Why: older hosts drop automation.list's selector and answer with the whole authority, so a scoped client must not read that as one host's rows. diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index cea4f40a9db3..a9d92b5a57d8 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -1,5 +1,4 @@ import type { CreateWorktreeResult } from './worktree/create-types' -import type { WorktreeRemovalOutcome } from './worktree/removal-outcome' import type { WorktreeDefaultTabsLaunch, WorktreeSetupLaunch, @@ -11,7 +10,7 @@ import type { RuntimeNativeChatLaunchDraftResolution } from './runtime-types' export type RuntimeClientEvent = | { type: 'reposChanged' } - | { type: 'worktreesChanged'; repoId: string; removalOutcome?: WorktreeRemovalOutcome } + | { type: 'worktreesChanged'; repoId: string } | ({ type: 'nativeChatLaunchDraftResolved' } & RuntimeNativeChatLaunchDraftResolution) | { type: 'terminalSideEffects'; batch: TerminalSideEffectBatch } // Why: SSH connections live on the runtime host; paired clients have no IPC diff --git a/src/shared/worktree/archive-hook-removal-gate.ts b/src/shared/worktree/archive-hook-removal-gate.ts index 9dcf270d28e1..d239bc212e22 100644 --- a/src/shared/worktree/archive-hook-removal-gate.ts +++ b/src/shared/worktree/archive-hook-removal-gate.ts @@ -10,6 +10,15 @@ */ export const ARCHIVE_HOOK_TIMEOUT_MS = 120_000 +/** + * How long a client waits for worktree.rm's reply. Git's checkout delete has no timeout (measured + * up to ~60 s under load) and a bulk delete queues behind the host's two-at-a-time limit; a client + * that gives up instead settles the card from the host's listing, so this is not a failure deadline. + */ +export function worktreeRemovalReplyTimeoutMs(runsArchiveHook: boolean): number { + return (runsArchiveHook ? ARCHIVE_HOOK_TIMEOUT_MS : 0) + 180_000 +} + /** RPC/CLI error code for a removal refused because the repo's archive hook did not succeed. */ export const ARCHIVE_HOOK_FAILED_REMOVAL_CODE = 'worktree_archive_hook_failed' diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index deffe2cfff40..5032df3924c3 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -215,8 +215,8 @@ export type RemoveWorktreeResult = { preservedBranch?: PreservedWorktreeBranch /** Present only when a FAILED archive hook was explicitly waived for this removal (#19334). */ archiveHookOverride?: ArchiveHookOverride - /** The host accepted the removal and deletes in the background; the result arrives as the - * `removalOutcome` of a later worktrees-changed event. Older hosts omit it and reply when done. */ + /** The host accepted the removal and is still deleting the checkout. Sent only to clients that + * cannot show a removal in progress; the others get the reply when the delete has finished. */ removing?: true } diff --git a/src/shared/worktree/removal-outcome.ts b/src/shared/worktree/removal-outcome.ts deleted file mode 100644 index 6b88242fa999..000000000000 --- a/src/shared/worktree/removal-outcome.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { WorktreeCatalogVersion } from './catalog-version' -import type { PreservedWorktreeBranch } from './create-types' - -/** How a background worktree removal ended, published once on the worktrees-changed event. */ -export type WorktreeRemovalOutcome = - | { - worktreeId: string - status: 'removed' - preservedBranch?: PreservedWorktreeBranch - /** The catalog the finished removal produced. */ - catalogVersion?: WorktreeCatalogVersion - } - | { worktreeId: string; status: 'failed'; error: string } From c13bece2de0c6becce85443e6669c69e30d15ff0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:37:32 -0700 Subject: [PATCH 20/46] test(worktrees): type the pending-removal host id in the background-removal suite --- src/main/worktree-background-removal.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts index 96c24775e0bb..d3a86be92dad 100644 --- a/src/main/worktree-background-removal.test.ts +++ b/src/main/worktree-background-removal.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../shared/execution-host' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, @@ -18,7 +19,7 @@ const removal = { deleteBranch: true, force: false } -const isPending = (hostId?: string): boolean => +const isPending = (hostId?: ExecutionHostId): boolean => waitForPendingWorktreeRemoval(removal.worktreeId, hostId) !== undefined function deferred(): { promise: Promise; resolve: (value: T) => void } { From aafc7eb84e7c8c33dd06422ed2b4a97bcc71a1ce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:46:34 -0700 Subject: [PATCH 21/46] fix(worktrees): answer a delete request even when a concurrent removal of the same worktree replaced its record The desktop app's removal and the runtime removal (CLI, paired clients) coalesce separately, so both can be accepted for one worktree. The second replaced the first's record, and the first delete then finished without resolving the request waiting on it, leaving the desktop card on Deleting indefinitely. Each delete now settles the request it was started for. --- src/main/worktree-background-removal.test.ts | 27 ++++++++++++++++++++ src/main/worktree-background-removal.ts | 11 +++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts index d3a86be92dad..c1aba566a4db 100644 --- a/src/main/worktree-background-removal.test.ts +++ b/src/main/worktree-background-removal.test.ts @@ -75,6 +75,33 @@ describe('background worktree removal', () => { expect(run).toHaveBeenCalledTimes(1) }) + it('answers a request whose delete a concurrent removal of the same worktree replaced', async () => { + // The desktop IPC and runtime RPC removal paths coalesce separately, so both can be accepted. + const first = deferred>() + const second = deferred<{ preservedBranch: { branchName: string; head: string } }>() + const firstResult = startBackgroundWorktreeRemoval({ + removal, + run: () => first.promise, + publish: () => {} + }) + const secondResult = startBackgroundWorktreeRemoval({ + removal, + run: () => second.promise, + publish: () => {} + }) + + first.resolve({}) + await expect(firstResult).resolves.toEqual({}) + expect(isPending()).toBe(true) + + second.resolve({ preservedBranch: { branchName: 'feature', head: 'abc' } }) + await expect(secondResult).resolves.toEqual({ + preservedBranch: { branchName: 'feature', head: 'abc' } + }) + await _settlePendingWorktreeRemovalsForTests() + expect(isPending()).toBe(false) + }) + it('rejects with the delete error and clears the row so a retry starts over', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const result = startBackgroundWorktreeRemoval({ diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index f49771f90fcd..f1cc8dbb2fc9 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -189,6 +189,7 @@ function runBackgroundWorktreeRemoval( stopControllers.add(controller) const settled: Promise = settleBackgroundWorktreeRemoval( record, + settlementsByWorktreeId.get(record.worktreeId), job, recorded, controller.signal @@ -203,6 +204,7 @@ function runBackgroundWorktreeRemoval( async function settleBackgroundWorktreeRemoval( record: WorktreeRemovalRecord, + settlement: RemovalSettlement | undefined, job: BackgroundWorktreeRemovalJob, recorded: Promise, stopSignal: AbortSignal @@ -227,11 +229,12 @@ async function settleBackgroundWorktreeRemoval( const cleared = pendingByWorktreeId.get(record.worktreeId) === record if (cleared) { pendingByWorktreeId.delete(record.worktreeId) - const settlement = settlementsByWorktreeId.get(record.worktreeId) settlementsByWorktreeId.delete(record.worktreeId) - if (settlement) { - settle(settlement) - } + } + // Why this run's own settlement: desktop IPC and runtime RPC coalesce separately, so a concurrent + // removal can replace the record, and the request waiting on this delete must still get its reply. + if (settlement) { + settle(settlement) } // Why notify before the clear is on disk: the clear is bookkeeping; a crash before it lands only // re-runs a finish that re-derives what is left from Git. From 032e520afccf5ba2767a33572e0cf0af6c4cad6a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:00:32 -0700 Subject: [PATCH 22/46] fix(worktrees): run same-repo removal archive hooks and teardown one at a time on the host Local bulk delete now sends same-repo removals in parallel, so their archive hooks, terminal teardown and preflight ran at once; a hook that writes refs can race the repo's ref locks (#2259). The host now serializes each local removal up to acceptance per repo, for every client; Git's checkout delete still runs in parallel under the delete limit. --- .../ipc/worktrees-background-removal.test.ts | 42 ++++++++++++++++++- .../register-worktree-removal-handlers.ts | 14 +++---- ...worktree-removal-archive-hook-gate.spec.ts | 25 +++++++++++ ...ntime-registered-local-worktree-removal.ts | 14 ++++++- src/main/worktree-removal-acceptance-queue.ts | 15 +++++++ 5 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 src/main/worktree-removal-acceptance-queue.ts diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index 9cf53aa236f9..c26f36b472d7 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { addWorktreeMock, + getEffectiveHooksMock, listWorktreesMock, - removeWorktreeMock + removeWorktreeMock, + runHookMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { mockKnownFeatureWorktree } from './worktrees-test-fixtures' @@ -192,6 +194,44 @@ describe('worktrees:remove in the background', () => { expect(removeWorktreeMock).toHaveBeenCalledTimes(1) }) + it('runs same-repo archive hooks one at a time while their Git deletes overlap', async () => { + const [main, feature] = mockKnownFeatureWorktree() + const secondId = 'repo-1::/workspace/second-wt' + listWorktreesMock.mockResolvedValue([ + main, + feature, + { ...feature, path: '/workspace/second-wt', branch: 'second', head: 'second' } + ]) + getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'git update-ref refs/x HEAD' } }) + const hookReleases: (() => void)[] = [] + runHookMock.mockImplementation( + () => + new Promise((resolve) => hookReleases.push(() => resolve({ success: true, output: '' }))) + ) + const gitReleases: (() => void)[] = [] + removeWorktreeMock.mockImplementation( + () => new Promise((resolve) => gitReleases.push(() => resolve({}))) + ) + + const first = remove({ worktreeId: featureId }) + const second = remove({ worktreeId: secondId }) + await vi.waitFor(() => expect(runHookMock).toHaveBeenCalledTimes(1)) + // Let the second request reach the host's acceptance queue before checking it waits there. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(runHookMock).toHaveBeenCalledTimes(1) + + hookReleases[0]?.() + await vi.waitFor(() => expect(runHookMock).toHaveBeenCalledTimes(2)) + hookReleases[1]?.() + await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(2)) + + for (const release of gitReleases) { + release() + } + await expect(first).resolves.not.toHaveProperty('removing') + await expect(second).resolves.not.toHaveProperty('removing') + }) + it('gives a create with the same name the next free name while the delete runs', async () => { mockKnownFeatureWorktree() blockGitRemove() diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index 2d1a7a6b7784..cecaa2c71ed6 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -16,6 +16,7 @@ import { finishAcceptedWorktreeRemoval, waitForPendingWorktreeRemoval } from '../../../worktree-background-removal' +import { runSerializedWorktreeRemovalAcceptance } from '../../../worktree-removal-acceptance-queue' export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { const { store, options, worktreeRemovalsInFlight } = context @@ -47,14 +48,11 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. const removal = withWorktreeSpan({ stage: 'remove', path: worktreePath }, async () => { - const accepted = await executeWorktreeRemoval( - context, - args, - repo, - repoId, - worktreePath, - removalHostId - ) + const accept = (): Promise => + executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) + const accepted = await (repo.connectionId + ? accept() + : runSerializedWorktreeRemovalAcceptance(repo.path, accept)) const result = await finishAcceptedWorktreeRemoval(accepted, args.worktreeId, removalHostId) // A background job reports its own lifecycle when Git finishes. if (!accepted.removing) { diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts index 1333d6bd469d..9e3a9bf4b554 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts @@ -23,6 +23,7 @@ import { ARCHIVE_HOOK_FAILED_REMOVAL_CODE, asArchiveHookRefusal } from '../../../shared/worktree/archive-hook-removal-gate' +import { runSerializedWorktreeRemovalAcceptance } from '../../worktree-removal-acceptance-queue' function withArchiveHook(): void { vi.mocked(getEffectiveHooks).mockReturnValue({ @@ -206,6 +207,30 @@ describe('archive hook removal gate', () => { expect(removeWorktree).toHaveBeenCalled() }) + it('holds a desktop removal in the same repo until this one is accepted', async () => { + const runtime = createWorktreeRemovalRuntime() + withArchiveHook() + const hookRun = deferred<{ success: boolean; output: string; exitCode?: number }>() + vi.mocked(runHook).mockReturnValue(hookRun.promise) + vi.mocked(removeWorktree).mockResolvedValue({}) + + const removal = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { + force: false, + runHooks: true + }) + await vi.waitFor(() => expect(runHook).toHaveBeenCalled()) + // The desktop IPC path queues its acceptance on this same per-repo queue. + const sibling = vi.fn(async () => {}) + const siblingAccepted = runSerializedWorktreeRemovalAcceptance(TEST_REPO_PATH, sibling) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(sibling).not.toHaveBeenCalled() + + hookRun.resolve({ success: true, output: '', exitCode: 0 }) + await siblingAccepted + expect(sibling).toHaveBeenCalledTimes(1) + await expect(removal).resolves.toEqual({}) + }) + it('does not coalesce an override retry onto the refusal already in flight', async () => { const runtime = createWorktreeRemovalRuntime() withArchiveHook() diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index dd28c1a2a07a..e6b4344226a3 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -27,8 +27,18 @@ import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' import { removesInBackground, startBackgroundWorktreeRemoval } from '../worktree-background-removal' +import { runSerializedWorktreeRemovalAcceptance } from '../worktree-removal-acceptance-queue' -export async function removeRuntimeRegisteredLocalWorktree(args: { +/** Runs after the previous same-repo removal was accepted; see runSerializedWorktreeRemovalAcceptance. */ +export function removeRuntimeRegisteredLocalWorktree( + args: Parameters[0] +): Promise { + return runSerializedWorktreeRemovalAcceptance(args.repo.path, () => + acceptRuntimeRegisteredLocalWorktreeRemoval(args) + ) +} + +async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: { repo: Repo target: RuntimeWorktreeRemovalTarget registeredWorktree: GitWorktreeInfo @@ -169,7 +179,7 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { } export type RuntimeLocalWorktreeRemovalFinishArgs = Pick< - Parameters[0], + Parameters[0], | 'repo' | 'removedPushTarget' | 'store' diff --git a/src/main/worktree-removal-acceptance-queue.ts b/src/main/worktree-removal-acceptance-queue.ts new file mode 100644 index 000000000000..e7248b0b7a05 --- /dev/null +++ b/src/main/worktree-removal-acceptance-queue.ts @@ -0,0 +1,15 @@ +import { runKeyedSerializedOperation } from './cli/keyed-promise-queue' + +const acceptanceQueueByRepo = new Map>() + +/** + * Runs one local removal's archive hook, preflight and teardown only after the previous removal in + * the same repo was accepted, whichever client asked: hooks that write refs race the repo's ref + * locks (#2259). Git's checkout delete runs after acceptance, in parallel under its own limit. + */ +export function runSerializedWorktreeRemovalAcceptance( + repoPath: string, + accept: () => Promise +): Promise { + return runKeyedSerializedOperation(acceptanceQueueByRepo, repoPath, accept) +} From 52cdae909571a88d90719866956e272a3b2a7b77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:01:59 -0700 Subject: [PATCH 23/46] fix(runtime): keep waiting worktree deletes out of a host's foreground call slots worktree.rm now replies only after Git deletes the checkout (up to minutes), so on paired desktop and web each waiting delete held one of the host's 8 foreground call slots, and a bulk delete queued listing refreshes and every other foreground call behind it. Deletes now run in their own lane with the same bound; the 2-slot background lane stays for status polls. --- src/shared/runtime-rpc-call-queue.test.ts | 25 +++++++++++++++++++++++ src/shared/runtime-rpc-call-queue.ts | 16 +++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/shared/runtime-rpc-call-queue.test.ts b/src/shared/runtime-rpc-call-queue.test.ts index 0c0219d05f2e..636698bf0e83 100644 --- a/src/shared/runtime-rpc-call-queue.test.ts +++ b/src/shared/runtime-rpc-call-queue.test.ts @@ -44,6 +44,31 @@ describe('runtime RPC call queue', () => { await expect(background2).resolves.toBe('background-2') }) + it('runs foreground calls while worktree deletes wait, still bounding the deletes', async () => { + const queue = new RuntimeRpcCallQueuePool(2, 1) + const started: string[] = [] + const pending: (() => void)[] = [] + const deletes = ['rm-1', 'rm-2', 'rm-3'].map((label) => + queue.enqueue('web-runtime', 'worktree.rm', async () => { + started.push(label) + await new Promise((resolve) => pending.push(resolve)) + return label + }) + ) + await vi.waitFor(() => expect(started).toEqual(['rm-1', 'rm-2'])) + + const listing = queue.enqueue('web-runtime', 'worktree.list', async () => 'listed') + await expect(listing).resolves.toBe('listed') + expect(started).toEqual(['rm-1', 'rm-2']) + + pending.shift()?.() + await expect(deletes[0]).resolves.toBe('rm-1') + await vi.waitFor(() => expect(started).toEqual(['rm-1', 'rm-2', 'rm-3'])) + pending.shift()?.() + pending.shift()?.() + await expect(Promise.all(deletes)).resolves.toEqual(['rm-1', 'rm-2', 'rm-3']) + }) + it('frees the queue slot when a runtime call throws synchronously', async () => { const queue = new RuntimeRpcCallQueuePool(1, 1) const first = queue.enqueue('web-runtime', 'status.get', () => { diff --git a/src/shared/runtime-rpc-call-queue.ts b/src/shared/runtime-rpc-call-queue.ts index 7a3c1842602b..132c80992734 100644 --- a/src/shared/runtime-rpc-call-queue.ts +++ b/src/shared/runtime-rpc-call-queue.ts @@ -51,6 +51,12 @@ export function isBackgroundRuntimeMethod(method: string): boolean { ) } +// Why its own lane: worktree.rm replies only when Git has deleted the checkout, so its calls would +// hold the foreground slots listing refreshes need; the background lane's slots belong to status. +function isLongWaitRuntimeMethod(method: string): boolean { + return method === 'worktree.rm' +} + export class RuntimeRpcCallQueuePool { private readonly queues = new Map() private queuedCallCount = 0 @@ -74,10 +80,12 @@ export class RuntimeRpcCallQueuePool { if (signal?.aborted) { return Promise.reject(abortSignalReason(signal)) } + // Same concurrency bound, counted apart from the selector's other calls; global caps still apply. + const queueKey = isLongWaitRuntimeMethod(method) ? `${selector}\u0000long-wait` : selector if (this.queuedCallCount >= this.maxQueuedTotal) { return Promise.reject(new RuntimeRpcCallQueueOverloadError('global')) } - const existingQueue = this.queues.get(selector) + const existingQueue = this.queues.get(queueKey) if (existingQueue && this.queuedCount(existingQueue) >= this.maxQueuedPerSelector) { return Promise.reject(new RuntimeRpcCallQueueOverloadError('selector')) } @@ -89,7 +97,7 @@ export class RuntimeRpcCallQueuePool { return Promise.reject(new RuntimeRpcCallQueueOverloadError('memory')) } - const queue = this.getQueue(selector) + const queue = this.getQueue(queueKey) return new Promise((resolve, reject) => { const call: QueuedRuntimeCall = { background: isBackgroundRuntimeMethod(method), @@ -105,10 +113,10 @@ export class RuntimeRpcCallQueuePool { this.queuedCallCount += 1 this.retainedCallBytes += retainedBytes if (signal) { - call.onAbort = () => this.cancelQueuedCall(selector, queue, call) + call.onAbort = () => this.cancelQueuedCall(queueKey, queue, call) signal.addEventListener('abort', call.onAbort, { once: true }) } - this.pump(selector, queue) + this.pump(queueKey, queue) }) } From c34bcc28b6b13c6609b2578098cd2bdbd17b1bd5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:13:25 -0700 Subject: [PATCH 24/46] fix(worktrees): join a same-worktree delete accepted while a removal waited its repo turn The desktop app and the runtime (CLI, paired clients, web) check for a running delete before they queue for the repo's acceptance turn. A delete of the same worktree from the other path, accepted while this one queued, was missed: this request re-ran the archive hook, stopped the terminals again and started a second `git worktree remove` on the directory Git was deleting. The queued acceptance now re-checks and joins the running delete. --- .../ipc/worktrees-background-removal.test.ts | 56 ++++++++++++++++++- .../register-worktree-removal-handlers.ts | 7 ++- ...worktree-removal-archive-hook-gate.spec.ts | 39 +++++++++++++ ...ntime-registered-local-worktree-removal.ts | 15 ++++- 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index c26f36b472d7..ead826e7d629 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -9,7 +9,10 @@ import { import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { mockKnownFeatureWorktree } from './worktrees-test-fixtures' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import { _resetPendingWorktreeRemovalsForTests } from '../worktree-background-removal' +import { + _resetPendingWorktreeRemovalsForTests, + startBackgroundWorktreeRemoval +} from '../worktree-background-removal' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -232,6 +235,57 @@ describe('worktrees:remove in the background', () => { await expect(second).resolves.not.toHaveProperty('removing') }) + it('joins a removal another client got accepted while this request waited its turn', async () => { + const [main, feature] = mockKnownFeatureWorktree() + const secondId = 'repo-1::/workspace/second-wt' + listWorktreesMock.mockResolvedValue([ + main, + feature, + { ...feature, path: '/workspace/second-wt', branch: 'second', head: 'second' } + ]) + getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'true' } }) + let releaseHook!: () => void + runHookMock.mockResolvedValue({ success: true, output: '' }) + runHookMock.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseHook = () => resolve({ success: true, output: '' }) + }) + ) + removeWorktreeMock.mockResolvedValue({}) + + const holdsTurn = remove({ worktreeId: secondId }) + await vi.waitFor(() => expect(runHookMock).toHaveBeenCalledTimes(1)) + const queued = remove({ worktreeId: featureId }) + await new Promise((resolve) => setTimeout(resolve, 20)) + // Stands in for the runtime path (CLI, paired clients), which coalesces apart from this one. + let finishOther: ((result: RemoveWorktreeResult) => void) | undefined + void startBackgroundWorktreeRemoval({ + removal: { + worktreeId: featureId, + repoId: 'repo-1', + repoPath: '/workspace/repo', + worktree: feature, + deleteBranch: true, + force: false + }, + run: () => + new Promise((resolve) => { + finishOther = resolve + }), + publish: () => {} + }) + releaseHook() + await holdsTurn + await vi.waitFor(() => expect(finishOther).toBeDefined()) + + const preserved = { preservedBranch: { branchName: 'feature', head: 'feature' } } + finishOther?.(preserved) + await expect(queued).resolves.toMatchObject(preserved) + expect(runHookMock).toHaveBeenCalledTimes(1) + expect(removeWorktreeMock).toHaveBeenCalledTimes(1) + }) + it('gives a create with the same name the next free name while the delete runs', async () => { mockKnownFeatureWorktree() blockGitRemove() diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index cecaa2c71ed6..d91743b01850 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -48,8 +48,11 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. const removal = withWorktreeSpan({ stage: 'remove', path: worktreePath }, async () => { - const accept = (): Promise => - executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) + const accept = async (): Promise => + // Why: another client's removal of this worktree may have been accepted during the wait. + waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) + ? { removing: true } + : executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) const accepted = await (repo.connectionId ? accept() : runSerializedWorktreeRemovalAcceptance(repo.path, accept)) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts index 9e3a9bf4b554..ee5d67745e64 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts @@ -24,6 +24,8 @@ import { asArchiveHookRefusal } from '../../../shared/worktree/archive-hook-removal-gate' import { runSerializedWorktreeRemovalAcceptance } from '../../worktree-removal-acceptance-queue' +import { startBackgroundWorktreeRemoval } from '../../worktree-background-removal' +import type { RemoveWorktreeResult } from '../../../shared/worktree/create-types' function withArchiveHook(): void { vi.mocked(getEffectiveHooks).mockReturnValue({ @@ -231,6 +233,43 @@ describe('archive hook removal gate', () => { await expect(removal).resolves.toEqual({}) }) + it('joins a desktop removal of the same worktree accepted while this one waited', async () => { + const runtime = createWorktreeRemovalRuntime() + withArchiveHook() + vi.mocked(runHook).mockResolvedValue({ success: true, output: '' }) + vi.mocked(removeWorktree).mockResolvedValue({}) + const siblingTurn = deferred() + void runSerializedWorktreeRemovalAcceptance(TEST_REPO_PATH, () => siblingTurn.promise) + + const removal = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { + force: false, + runHooks: true + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + // The desktop IPC path coalesces apart from this one; stand in for its accepted removal. + const desktopDelete = deferred() + void startBackgroundWorktreeRemoval({ + removal: { + worktreeId: TEST_WORKTREE_ID, + repoId: 'repo-1', + repoPath: TEST_REPO_PATH, + worktree: { path: TEST_WORKTREE_PATH, branch: 'feature', head: 'feature' }, + deleteBranch: true, + force: false + }, + run: () => desktopDelete.promise, + publish: () => {} + }) + siblingTurn.resolve() + await new Promise((resolve) => setTimeout(resolve, 20)) + const preserved = { preservedBranch: { branchName: 'feature', head: 'feature' } } + desktopDelete.resolve(preserved) + + await expect(removal).resolves.toMatchObject(preserved) + expect(runHook).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + }) + it('does not coalesce an override retry onto the refusal already in flight', async () => { const runtime = createWorktreeRemovalRuntime() withArchiveHook() diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index e6b4344226a3..a5e966b1471a 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -26,15 +26,24 @@ import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' -import { removesInBackground, startBackgroundWorktreeRemoval } from '../worktree-background-removal' +import { + removesInBackground, + startBackgroundWorktreeRemoval, + waitForPendingWorktreeRemoval +} from '../worktree-background-removal' import { runSerializedWorktreeRemovalAcceptance } from '../worktree-removal-acceptance-queue' /** Runs after the previous same-repo removal was accepted; see runSerializedWorktreeRemovalAcceptance. */ export function removeRuntimeRegisteredLocalWorktree( args: Parameters[0] ): Promise { - return runSerializedWorktreeRemovalAcceptance(args.repo.path, () => - acceptRuntimeRegisteredLocalWorktreeRemoval(args) + return runSerializedWorktreeRemovalAcceptance( + args.repo.path, + async (): Promise => + // Why: another client's removal of this worktree may have been accepted during the wait. + waitForPendingWorktreeRemoval(args.target.id) + ? { removing: true } + : acceptRuntimeRegisteredLocalWorktreeRemoval(args) ) } From 8c81fca02230a08dbf6d379c99c6561a187abe0f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:33:11 -0700 Subject: [PATCH 25/46] fix(worktrees): fence a resumed delete's checkout from startup, and drop rows a listing read before the delete finished A delete a quit or crash interrupted took its terminal and file-watcher gate only when the resume job ran, after the first window was shown; session restore could open a shell or watcher inside the half-deleted checkout first, and on Windows that handle can fail the resumed git delete. Loading the records now fences each recorded path, and the resumed job takes the fence over in the same tick it takes its own gate. A listing that read git's registration before a delete finished, and replied after the removal record cleared, returned the row unmarked, so other views briefly showed "The delete did not finish". Listings now capture the pending removals before reading git and leave out a row whose delete finished successfully since; a row whose delete failed stays listed as before. --- .../listing/detected-provider-listing.ts | 9 ++- .../register-worktree-catalog-handlers.ts | 19 ++++-- .../rpc/methods/worktree-catalog-methods.ts | 23 +++++-- ...worktree-removal-marker-projection.test.ts | 22 ++++-- .../rpc/worktree-removal-marker-projection.ts | 20 ++++-- ...interrupted-local-worktree-removal.test.ts | 17 ++++- ...time-interrupted-local-worktree-removal.ts | 11 ++- ...orktree-background-removal-records.test.ts | 19 ++++-- src/main/worktree-background-removal.test.ts | 61 +++++++++++++++-- src/main/worktree-background-removal.ts | 67 ++++++++++++++++--- 10 files changed, 220 insertions(+), 48 deletions(-) diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 3400c36ab0e8..8bf5d924b2ce 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -1,4 +1,7 @@ -import { projectPendingWorktreeRemovals } from '../../../worktree-background-removal' +import { + projectPendingWorktreeRemovals, + snapshotPendingWorktreeRemovals +} from '../../../worktree-background-removal' import { getRepoExecutionHostId, getSshTargetIdForExecutionHost @@ -129,6 +132,7 @@ export async function listDetectedWorktreesForCapturedRepo( catalogVersion: getLocalWorktreeCatalogVersion(repo.id) } } + const pendingAtScan = snapshotPendingWorktreeRemovals() const scan = await scanUntilNotOvertaken( repo.id, connectionId && provider @@ -191,7 +195,8 @@ export async function listDetectedWorktreesForCapturedRepo( projectPendingWorktreeRemovals( buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta), (worktree) => worktree.id, - true + true, + pendingAtScan ) } } catch (err) { diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index 94621a25ce41..c09c1224e88a 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -33,15 +33,22 @@ import { } from '../../../persistence/host-qualified-worktree-meta' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import type { Worktree } from '../../../../shared/worktree/types' -import { projectPendingWorktreeRemovals } from '../../../worktree-background-removal' +import { + projectPendingWorktreeRemovals, + snapshotPendingWorktreeRemovals, + type PendingWorktreeRemovals +} from '../../../worktree-background-removal' import { getLocalWorktreeScanGeneration } from '../../../local-worktree-scan-generation' import { getRegisteredWorktreeRootsRevision } from '../../registered-worktree-roots-cache' const WORKTREE_LIST_ALL_CONCURRENCY = 8 // Why always marked: the desktop renderer ships with this main process, so it reads the marker. -function markLocalWorktreesUnderRemoval(worktrees: T[]): T[] { - return projectPendingWorktreeRemovals(worktrees, (worktree) => worktree.id, true) +function markLocalWorktreesUnderRemoval( + worktrees: T[], + pendingAtScan: PendingWorktreeRemovals +): T[] { + return projectPendingWorktreeRemovals(worktrees, (worktree) => worktree.id, true, pendingAtScan) } async function mapWithConcurrency( @@ -109,6 +116,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo let sideEffectToken: DetectedWorktreeSideEffectToken | undefined let metadataPrune: DetectedWorktreeMetadataPrune | undefined let hygieneDue: boolean | undefined + const pendingAtScan = snapshotPendingWorktreeRemovals() if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) } else if (connectionId) { @@ -162,7 +170,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo const worktrees = buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) - return connectionId ? worktrees : markLocalWorktreesUnderRemoval(worktrees) + return connectionId ? worktrees : markLocalWorktreesUnderRemoval(worktrees, pendingAtScan) } catch (err) { warnOnce( loggedWorktreeListFailures, @@ -208,6 +216,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo let sideEffectToken: DetectedWorktreeSideEffectToken | undefined let metadataPrune: DetectedWorktreeMetadataPrune | undefined let hygieneDue: boolean | undefined + const pendingAtScan = snapshotPendingWorktreeRemovals() if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) } else if (connectionId) { @@ -255,7 +264,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo const worktrees = buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) - return connectionId ? worktrees : markLocalWorktreesUnderRemoval(worktrees) + return connectionId ? worktrees : markLocalWorktreesUnderRemoval(worktrees, pendingAtScan) } catch (err) { warnOnce( loggedWorktreeListFailures, diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index e7260d02f4a6..082d0400f7c8 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -5,6 +5,7 @@ import { projectWorktreeListRemovals, projectWorktreePsRemovals } from '../worktree-removal-marker-projection' +import { snapshotPendingWorktreeRemovals } from '../../../worktree-background-removal' import { WorktreeDetectedListParams, WorktreeListParams, @@ -16,6 +17,7 @@ export const WORKTREE_CATALOG_METHODS = [ name: 'worktree.ps', params: WorktreePsParams, handler: async (params, context) => { + const pendingAtScan = snapshotPendingWorktreeRemovals() const result = projectWorktreePsRemovals( await context.runtime.getWorktreePs( params.limit, @@ -24,7 +26,8 @@ export const WORKTREE_CATALOG_METHODS = [ params.supportsWorktreeVisibilitySourceDefaults ) ), - context + context, + pendingAtScan ) // Why: callers that never send the field get the byte-exact legacy response. return params.afterSnapshotId === undefined @@ -35,15 +38,18 @@ export const WORKTREE_CATALOG_METHODS = [ defineMethod({ name: 'worktree.list', params: WorktreeListParams, - handler: async (params, context) => - projectWorktreeListRemovals( + handler: async (params, context) => { + const pendingAtScan = snapshotPendingWorktreeRemovals() + return projectWorktreeListRemovals( await context.runtime.listManagedWorktrees( params.repo, params.limit, supportsWorktreeVisibilitySourceDefaults(context) ), - context + context, + pendingAtScan ) + } }), defineMethod({ name: 'worktree.listRetiredNames', @@ -53,14 +59,17 @@ export const WORKTREE_CATALOG_METHODS = [ defineMethod({ name: 'worktree.detectedList', params: WorktreeDetectedListParams, - handler: async (params, context) => - projectWorktreeListRemovals( + handler: async (params, context) => { + const pendingAtScan = snapshotPendingWorktreeRemovals() + return projectWorktreeListRemovals( await context.runtime.listDetectedManagedWorktrees( params.repo, undefined, supportsWorktreeVisibilitySourceDefaults(context) ), - context + context, + pendingAtScan ) + } }) ] diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts index 76c6916dc4a9..4ce22b0a3600 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts @@ -11,6 +11,7 @@ import type { } from '../../../shared/runtime-worktree-contracts' import { _resetPendingWorktreeRemovalsForTests, + snapshotPendingWorktreeRemovals, startBackgroundWorktreeRemoval } from '../../worktree-background-removal' import { @@ -67,11 +68,16 @@ describe('worktree listings while a checkout is being deleted', () => { it('marks the row for a desktop or web client that negotiated the marker', () => { const context = { clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } - expect(projectWorktreeListRemovals(listResult(), context).worktrees).toEqual([ + expect( + projectWorktreeListRemovals(listResult(), context, snapshotPendingWorktreeRemovals()) + .worktrees + ).toEqual([ { id: removingId, hostId: 'local', removing: true }, { id: keptId, hostId: 'local' } ]) - expect(projectWorktreePsRemovals(psResult(), context).worktrees).toEqual([ + expect( + projectWorktreePsRemovals(psResult(), context, snapshotPendingWorktreeRemovals()).worktrees + ).toEqual([ { worktreeId: removingId, hostId: 'local', removing: true }, { worktreeId: keptId, hostId: 'local' } ]) @@ -85,10 +91,18 @@ describe('worktree listings while a checkout is being deleted', () => { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, remoteRuntimeClientCapabilities() ]) { - const list = projectWorktreeListRemovals(listResult(), { clientCapabilities }) + const list = projectWorktreeListRemovals( + listResult(), + { clientCapabilities }, + snapshotPendingWorktreeRemovals() + ) expect(list.worktrees.map((row) => row.id)).toEqual([keptId]) expect(list.totalCount).toBe(1) - const ps = projectWorktreePsRemovals(psResult(), { clientCapabilities }) + const ps = projectWorktreePsRemovals( + psResult(), + { clientCapabilities }, + snapshotPendingWorktreeRemovals() + ) expect(ps.worktrees.map((row) => row.worktreeId)).toEqual([keptId]) expect(ps.totalCount).toBe(1) } diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.ts index b3e31c1417f9..f4f13ca9c418 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.ts @@ -4,7 +4,10 @@ import type { RuntimeWorktreeListResult, RuntimeWorktreePsResult } from '../../../shared/runtime-worktree-contracts' -import { projectPendingWorktreeRemovals } from '../../worktree-background-removal' +import { + projectPendingWorktreeRemovals, + type PendingWorktreeRemovals +} from '../../worktree-background-removal' import type { RpcContext } from './core' // Why no in-process default: callers without negotiation (the CLI, host-side readers) print or act @@ -19,11 +22,16 @@ export function readsWorktreeRemovalMarker( export function projectWorktreeListRemovals< T extends RuntimeWorktreeListResult | DetectedWorktreeListResult ->(result: T, context: Pick): T { +>( + result: T, + context: Pick, + pendingAtScan: PendingWorktreeRemovals +): T { const worktrees = projectPendingWorktreeRemovals( result.worktrees, (worktree) => worktree.id, - readsWorktreeRemovalMarker(context) + readsWorktreeRemovalMarker(context), + pendingAtScan ) if (worktrees === result.worktrees) { return result @@ -36,12 +44,14 @@ export function projectWorktreeListRemovals< export function projectWorktreePsRemovals( result: RuntimeWorktreePsResult, - context: Pick + context: Pick, + pendingAtScan: PendingWorktreeRemovals ): RuntimeWorktreePsResult { const worktrees = projectPendingWorktreeRemovals( result.worktrees, (summary) => summary.worktreeId, - readsWorktreeRemovalMarker(context) + readsWorktreeRemovalMarker(context), + pendingAtScan ) return worktrees === result.worktrees ? result diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index 036e2c797a8e..8fd770875878 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -17,6 +17,11 @@ import type * as WorktreeGitFileRestore from '../git/worktree-git-file-restore' import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' import { listWorktreesStrict } from '../git/worktree' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' +import { + acquireWatcherRemovalGate, + beginTerminalInstall, + beginWatcherInstall +} from '../ipc/watcher-removal-gate' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, @@ -115,6 +120,9 @@ async function finishAfterRestart(options: { repoGone?: boolean; head?: string } // A request that joins before the finish starts gets the finish's result. const joined = waitForPendingWorktreeRemoval(record.worktreeId) expect(joined).toBeDefined() + // Session restore runs before the resume: nothing may open a handle in the half-deleted checkout. + expect(() => beginTerminalInstall(worktreePath)).toThrow(/being removed/) + expect(() => beginWatcherInstall(worktreePath)).toThrow(/being removed/) const storeStub = { getRepo: (id: string) => (id === repo.id && !options.repoGone ? repo : undefined), @@ -127,7 +135,12 @@ async function finishAfterRestart(options: { repoGone?: boolean; head?: string } interruptedLocalWorktreeRemovalJob(interrupted, { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the finish reads only repos and worktree metadata from the store here; git options and push-target cleanup are stubbed or short-circuit without a push target. store: storeStub as unknown as Store, - acquireWatcherRemoval: async () => ({ finish: async () => {} }), + // Takes the real gate synchronously, as the runtime's does. + acquireWatcherRemoval: async (path) => { + const gate = acquireWatcherRemovalGate(path) + expect(() => beginTerminalInstall(worktreePath)).toThrow(/being removed/) + return { finish: async () => gate.release() } + }, closeWatchers: async () => {}, preservedBranchCleanup: { preserveHead: (result) => result ?? {}, @@ -145,6 +158,8 @@ async function finishAfterRestart(options: { repoGone?: boolean; head?: string } await _settlePendingWorktreeRemovalsForTests() expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) expect(waitForPendingWorktreeRemoval(record.worktreeId)).toBeUndefined() + // Released on every outcome, including a finish that ended before taking its own gate. + beginTerminalInstall(worktreePath)() return { outcome, purged, remember } } diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index ed73a4c9df73..ca50faf3b8f1 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -5,7 +5,10 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { Store } from '../persistence' -import type { BackgroundWorktreeRemovalJob } from '../worktree-background-removal' +import { + releaseStartupRemovalFence, + type BackgroundWorktreeRemovalJob +} from '../worktree-background-removal' import { resolveWorktreeRemovalMetadata } from '../worktree-removal-repo-owner' import type { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' import { listWorktreesStrict } from '../git/worktree' @@ -52,7 +55,11 @@ export function interruptedLocalWorktreeRemovalJob( store: host.store, stopSignal, removedPushTarget, - acquireWatcherRemoval: host.acquireWatcherRemoval, + acquireWatcherRemoval: (path) => { + // Why same tick: the gate takes over the loading fence's path with no gap for a spawn. + releaseStartupRemovalFence(record.worktreeId) + return host.acquireWatcherRemoval(path) + }, closeWatchers: host.closeWatchers, preserveBranchHead: (result, fallbackHead) => host.preservedBranchCleanup.preserveHead(result, fallbackHead), diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index dd010c4451fd..6707acfe271d 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -8,6 +8,7 @@ import { loadWorktreeRemovalRecords, projectPendingWorktreeRemovals, resumeInterruptedWorktreeRemovals, + snapshotPendingWorktreeRemovals, startBackgroundWorktreeRemoval, stopBackgroundWorktreeRemovals, waitForPendingWorktreeRemoval @@ -192,13 +193,17 @@ describe('durable worktree removal records', () => { { id: removal.worktreeId }, { id: 'repo-1::/work/other' } ] - expect(projectPendingWorktreeRemovals(rows, (row) => row.id, true)).toEqual([ - { id: removal.worktreeId, removing: true }, - { id: 'repo-1::/work/other' } - ]) - expect(projectPendingWorktreeRemovals(rows, (row) => row.id, false)).toEqual([ - { id: 'repo-1::/work/other' } - ]) + expect( + projectPendingWorktreeRemovals(rows, (row) => row.id, true, snapshotPendingWorktreeRemovals()) + ).toEqual([{ id: removal.worktreeId, removing: true }, { id: 'repo-1::/work/other' }]) + expect( + projectPendingWorktreeRemovals( + rows, + (row) => row.id, + false, + snapshotPendingWorktreeRemovals() + ) + ).toEqual([{ id: 'repo-1::/work/other' }]) const publish = vi.fn() const resumed: string[] = [] diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts index c1aba566a4db..2ddc1952e8eb 100644 --- a/src/main/worktree-background-removal.test.ts +++ b/src/main/worktree-background-removal.test.ts @@ -7,6 +7,7 @@ import { finishAcceptedWorktreeRemoval, projectPendingWorktreeRemovals, removesInBackground, + snapshotPendingWorktreeRemovals, startBackgroundWorktreeRemoval, waitForPendingWorktreeRemoval } from './worktree-background-removal' @@ -182,15 +183,56 @@ describe('background worktree removal', () => { { id: removal.worktreeId, hostId: 'ssh:box' as const } ] - expect(projectPendingWorktreeRemovals(rows, (row) => row.id, true)).toEqual([ + expect( + projectPendingWorktreeRemovals(rows, (row) => row.id, true, snapshotPendingWorktreeRemovals()) + ).toEqual([ { id: removal.worktreeId, hostId: 'local', removing: true }, { id: 'repo-1::/work/other' }, { id: removal.worktreeId, hostId: 'ssh:box' } ]) - expect(projectPendingWorktreeRemovals(rows, (row) => row.id, false)).toEqual([ - { id: 'repo-1::/work/other' }, - { id: removal.worktreeId, hostId: 'ssh:box' } - ]) + expect( + projectPendingWorktreeRemovals( + rows, + (row) => row.id, + false, + snapshotPendingWorktreeRemovals() + ) + ).toEqual([{ id: 'repo-1::/work/other' }, { id: removal.worktreeId, hostId: 'ssh:box' }]) + }) + + it('drops a row a listing read before Git finished deleting it, and keeps one whose delete failed', async () => { + const other = { ...removal, worktreeId: 'repo-1::/work/other' } + const removed = deferred>() + const failed = deferred>() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + void startBackgroundWorktreeRemoval({ removal, run: () => removed.promise, publish: () => {} }) + void startBackgroundWorktreeRemoval({ + removal: other, + run: async () => { + await failed.promise + throw new Error('git failed') + }, + publish: () => {} + }) + // The listing reads Git while both deletes run and replies after both ended. + const pendingAtScan = snapshotPendingWorktreeRemovals() + removed.resolve({}) + failed.resolve({}) + await _settlePendingWorktreeRemovalsForTests() + const rows: { id: string; hostId?: undefined }[] = [ + { id: removal.worktreeId }, + { id: other.worktreeId } + ] + + for (const clientReadsMarker of [true, false]) { + expect( + projectPendingWorktreeRemovals(rows, (row) => row.id, clientReadsMarker, pendingAtScan) + ).toEqual([{ id: other.worktreeId }]) + } + // A listing that began after the delete finished reads Git's current answer as-is. + expect( + projectPendingWorktreeRemovals(rows, (row) => row.id, true, snapshotPendingWorktreeRemovals()) + ).toBe(rows) }) it('keeps WSL checkouts on the inline delete', () => { @@ -200,6 +242,13 @@ describe('background worktree removal', () => { it('returns listings untouched when nothing is being removed', () => { const rows: { id: string; hostId?: undefined }[] = [{ id: removal.worktreeId }] - expect(projectPendingWorktreeRemovals(rows, (row) => row.id, false)).toBe(rows) + expect( + projectPendingWorktreeRemovals( + rows, + (row) => row.id, + false, + snapshotPendingWorktreeRemovals() + ) + ).toBe(rows) }) }) diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index f1cc8dbb2fc9..be6bb365d5ae 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -3,6 +3,7 @@ import type { RemoveWorktreeResult } from '../shared/worktree/create-types' import type { GitWorktreeInfo } from '../shared/worktree/types' import { normalizeLocalBranchRef } from './git/worktree-operation-options' import { areWorktreePathsEqual } from './git/worktree-path-comparison' +import { acquireWatcherRemovalGate, type WatcherRemovalGate } from './ipc/watcher-removal-gate' import { parseWslPath } from './wsl' import { readWorktreeRemovalRecords, @@ -17,6 +18,9 @@ export type BackgroundWorktreeRemovalJob = { publish: () => void } +/** The removals pending when a listing began to read Git. */ +export type PendingWorktreeRemovals = ReadonlyMap + type RemovalSettlement = { result: Promise resolve: (result: RemoveWorktreeResult) => void @@ -29,18 +33,40 @@ const jobsByWorktreeId = new Map>() // What every request for a pending removal waits on: the first one and any that join it. const settlementsByWorktreeId = new Map() const stopControllers = new Set() +// Loaded removals' terminal/watcher fences, held until the resumed job takes its own gate. +const startupFencesByWorktreeId = new Map() +// Why weak: a listing that read Git before a delete finished holds the record until it replies. +const removedRecords = new WeakSet() +const NO_PENDING_REMOVALS: PendingWorktreeRemovals = new Map() let recordsDirectory: string | null = null -/** Loads removals a quit or crash interrupted, so listings mark them before the first paint. */ +/** + * Loads removals a quit or crash interrupted, so listings mark them before the first paint and + * session restore cannot open a terminal or watcher in a half-deleted checkout before the resume. + */ export async function loadWorktreeRemovalRecords(directory: string): Promise { recordsDirectory = directory for (const record of await readWorktreeRemovalRecords(directory)) { if (!pendingByWorktreeId.has(record.worktreeId)) { addPendingRemoval(record) + try { + startupFencesByWorktreeId.set( + record.worktreeId, + acquireWatcherRemovalGate(record.worktreePath) + ) + } catch { + // An overlapping loaded removal already fences this path. + } } } } +/** Hands a loaded removal's fence to its resumed job; call in the same tick the job takes its gate. */ +export function releaseStartupRemovalFence(worktreeId: string): void { + startupFencesByWorktreeId.get(worktreeId)?.release() + startupFencesByWorktreeId.delete(worktreeId) +} + function addPendingRemoval(record: WorktreeRemovalRecord): RemovalSettlement { let resolve!: RemovalSettlement['resolve'] let reject!: RemovalSettlement['reject'] @@ -216,6 +242,7 @@ async function settleBackgroundWorktreeRemoval( return } const result = await job.run(stopSignal) + removedRecords.add(record) settle = (settlement) => settlement.resolve(result) } catch (error) { if (stopSignal.aborted) { @@ -224,6 +251,9 @@ async function settleBackgroundWorktreeRemoval( } console.warn(`[worktrees] background removal of ${record.worktreePath} failed`, error) settle = (settlement) => settlement.reject(error) + } finally { + // A resumed job that ended before taking its own gate still holds the fence loading gave it. + releaseStartupRemovalFence(record.worktreeId) } // Why clear on failure too: the row returns live and retryable instead of retrying unseen. const cleared = pendingByWorktreeId.get(record.worktreeId) === record @@ -276,25 +306,40 @@ function publishSafely(publish: () => void): void { } } +/** Taken before a listing reads Git; pass it to projectPendingWorktreeRemovals with the rows. */ +export function snapshotPendingWorktreeRemovals(): PendingWorktreeRemovals { + return pendingByWorktreeId.size === 0 ? NO_PENDING_REMOVALS : new Map(pendingByWorktreeId) +} + /** * Marks rows whose checkout this host is deleting, or leaves them out for a client that cannot * read the marker: such a client already dropped the row on acceptance and would re-show it. */ export function projectPendingWorktreeRemovals< T extends { hostId?: ExecutionHostId; removing?: true } ->(rows: T[], idOf: (row: T) => string, clientReadsMarker: boolean): T[] { - if (pendingByWorktreeId.size === 0) { +>( + rows: T[], + idOf: (row: T) => string, + clientReadsMarker: boolean, + pendingAtScan: PendingWorktreeRemovals +): T[] { + if (pendingByWorktreeId.size === 0 && pendingAtScan.size === 0) { return rows } const projected: T[] = [] for (const row of rows) { - const pending = - (row.hostId === undefined || row.hostId === LOCAL_EXECUTION_HOST_ID) && - pendingByWorktreeId.has(idOf(row)) - if (!pending) { + const id = idOf(row) + const local = row.hostId === undefined || row.hostId === LOCAL_EXECUTION_HOST_ID + if (local && pendingByWorktreeId.has(id)) { + if (clientReadsMarker) { + projected.push({ ...row, removing: true }) + } + continue + } + const scanned = local ? pendingAtScan.get(id) : undefined + // Why: Git was read before this delete finished; unmarked, the gone row reads as a failed delete. + if (!scanned || !removedRecords.has(scanned)) { projected.push(row) - } else if (clientReadsMarker) { - projected.push({ ...row, removing: true }) } } return projected @@ -311,5 +356,9 @@ export function _resetPendingWorktreeRemovalsForTests(): void { jobsByWorktreeId.clear() settlementsByWorktreeId.clear() stopControllers.clear() + for (const fence of startupFencesByWorktreeId.values()) { + fence.release() + } + startupFencesByWorktreeId.clear() recordsDirectory = null } From 567cb50b5d412b5816e1a2cd1d30b94d5562db9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:47:23 -0700 Subject: [PATCH 26/46] test(worktrees): keep git's auto-maintenance out of the real-git removal suite CI's Git 2.55 failed the file-pool test in teardown with ENOTEMPTY on the scratch repo's objects/pack after the test body passed: the 3,000-file commit's detached auto-maintenance was still writing a pack. The scratch repo now disables auto-maintenance and auto-gc. --- src/main/git/worktree-removal-real-git.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/git/worktree-removal-real-git.test.ts b/src/main/git/worktree-removal-real-git.test.ts index f03554b7a1a2..2df5f7fa46a1 100644 --- a/src/main/git/worktree-removal-real-git.test.ts +++ b/src/main/git/worktree-removal-real-git.test.ts @@ -46,6 +46,9 @@ beforeEach(async () => { await git(['init', '-q'], repoPath) await git(['config', 'user.email', 'removal@example.invalid'], repoPath) await git(['config', 'user.name', 'Worktree Removal'], repoPath) + // Why: a commit's detached auto-maintenance can still be writing packs when teardown deletes the repo. + await git(['config', 'maintenance.auto', 'false'], repoPath) + await git(['config', 'gc.auto', '0'], repoPath) await writeFile(join(repoPath, 'seed.txt'), 'seed\n') // Committed before the worktree exists so its branch stays merged and branch cleanup can run. await mkdir(join(repoPath, 'node_modules', 'pkg'), { recursive: true }) From 2755748350ccddd800a76e41bb3e5c106e4ebafa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:00:17 -0700 Subject: [PATCH 27/46] fix(worktrees): one archive-hook approval covers a same-repo bulk delete again Local same-repo deletes now start together, so each queued its trust prompt with a state snapshot taken before the first prompt was answered; approving the first still showed the same prompt once per remaining worktree. The queued check now reads the store when its turn comes. --- .../src/lib/ensure-hooks-confirmed.test.ts | 30 +++++++++++++++++++ .../src/lib/ensure-hooks-confirmed.ts | 4 ++- .../worktrees/teardown/remove-worktree.ts | 3 +- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index b21f0f39c4cb..808378e0502a 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -555,6 +555,36 @@ describe('ensureHooksConfirmed', () => { await expect(second).resolves.toBe('run') }) + it('lets a queued prompt for the same script see the approval recorded by the one before it', async () => { + const created = createTestState() + const { pending } = created + let state = created.state + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: { archive: 'echo bye' } }, + mayNeedUpdate: false + }) + + const first = ensureHooksConfirmed(() => state, 'repo-1', 'archive') + const second = ensureHooksConfirmed(() => state, 'repo-1', 'archive') + + await vi.waitFor(() => expect(pending).toHaveLength(1)) + // The store replaces its state when the dialog records trust, as zustand does. + state = { + ...state, + trustedOrcaHooks: { + 'repo-1': { + archive: { contentHash: await hashOrcaHookScript('echo bye'), approvedAt: 1 } + } + } + } + pending[0].resolve('run') + + await expect(first).resolves.toBe('run') + await expect(second).resolves.toBe('run') + expect(pending).toHaveLength(1) + }) + it('fails closed when window.api.hooks.check throws', async () => { const { state, pending } = createTestState() hooksCheckMock.mockRejectedValue(new Error('boom')) diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index 0a24c776c629..d392033335c5 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -234,7 +234,8 @@ export async function readAndConfirmRuntimeIssueCommand( } export async function ensureHooksConfirmed( - state: AppState, + // Why a getter: a caller that queues behind an earlier prompt must see the trust it recorded. + stateOrGetter: AppState | (() => AppState), repoId: string, scriptKind: HookScriptKind, hostId?: ExecutionHostId, @@ -245,6 +246,7 @@ export async function ensureHooksConfirmed( if (isCancelled()) { return 'skip' } + const state = typeof stateOrGetter === 'function' ? stateOrGetter() : stateOrGetter if (canUseRepoWideTrust(state, repoId)) { return 'run' } diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 314e778648c5..3ca041aeb753 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -92,10 +92,11 @@ 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 ? true : (await ensureHooksConfirmed( - get(), + get, getRepoIdFromWorktreeId(worktreeId), 'archive', hostId, From ef3bc4f0c01296fea21433f188203622999ada76 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:59:57 -0700 Subject: [PATCH 28/46] fix(worktrees): a delete Git fails partway stays listed with its error; Delete retries it `git worktree remove --force` drops the checkout's registration even when it cannot delete a file (root-owned files, `chflags uchg`, a read-only Windows directory). Orca lists workspaces from Git, so the row vanished after the error, leaving the checkout, the branch and Orca's metadata with no way to retry. - A background delete that fails with the checkout still on disk, unregistered, and still the removed checkout's own leftover keeps its durable removal record with the error (`failure`) instead of clearing it. Every other failure clears it as before. - Local listings (desktop list/list-all/detected, runtime list/ps/detected) add a row for each such record, carrying `removalError`, and for a pending removal whose checkout Git no longer lists (shown as removing). - Delete on that row (desktop IPC and runtime worktree.rm) runs the recorded removal again: terminal teardown, then the leftover, prune, branch and metadata, under the per-host delete limit. - The record ends on a successful retry, when the checkout is gone (listing or startup), when a different checkout takes the path, or on forget-local. Startup never retries a failed record. - The finish's unregistered-path rule accepts a `.git` file naming the admin entry Git removed (the leftover's own) and still refuses any other `.git`. The removal table and listing projection move out of the background removal module into worktree-removal-table.ts and worktree-removal-listing.ts. --- src/main/git/worktree-add.ts | 2 +- src/main/ipc/worktree-metadata-merge.ts | 1 + src/main/ipc/worktree-remote.ts | 2 +- src/main/ipc/worktrees-failed-removal.test.ts | 215 +++++++++++++ .../listing/detected-provider-listing.ts | 10 +- .../register-worktree-catalog-handlers.ts | 13 +- .../register-worktree-forget-handlers.ts | 4 + .../register-worktree-removal-handlers.ts | 8 +- .../retry-failed-local-worktree-removal.ts | 62 ++++ src/main/repo-maintenance-idle-gate.ts | 2 +- ...-resolved-worktrees-for-explicit-target.ts | 20 +- .../orca-runtime-remove-managed-worktree.ts | 7 +- ...runtime-resolve-worktree-removal-target.ts | 61 +++- .../orca-runtime-stop-requested-pty-ids.ts | 2 +- .../worktree-removal-failed-retry.spec.ts | 90 ++++++ src/main/runtime/orca-runtime.test.ts | 1 + .../rpc/methods/worktree-catalog-methods.ts | 2 +- ...worktree-removal-marker-projection.test.ts | 2 +- .../rpc/worktree-removal-marker-projection.ts | 2 +- ...time-failed-local-worktree-removal.test.ts | 282 ++++++++++++++++++ ...time-interrupted-local-worktree-removal.ts | 23 +- ...runtime-local-worktree-create-candidate.ts | 2 +- .../runtime-worktree-ps-summaries.test.ts | 33 ++ .../runtime/runtime-worktree-ps-summaries.ts | 1 + ...orktree-background-removal-records.test.ts | 6 +- src/main/worktree-background-removal.test.ts | 8 +- src/main/worktree-background-removal.ts | 205 ++++++------- src/main/worktree-failed-removal.test.ts | 253 ++++++++++++++++ src/main/worktree-removal-leftover.ts | 22 ++ src/main/worktree-removal-listing.ts | 100 +++++++ src/main/worktree-removal-records.ts | 21 +- src/main/worktree-removal-table.ts | 83 ++++++ src/shared/runtime-worktree-contracts.ts | 2 + src/shared/worktree/types.ts | 3 + 34 files changed, 1395 insertions(+), 155 deletions(-) create mode 100644 src/main/ipc/worktrees-failed-removal.test.ts create mode 100644 src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts create mode 100644 src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts create mode 100644 src/main/runtime/runtime-failed-local-worktree-removal.test.ts create mode 100644 src/main/worktree-failed-removal.test.ts create mode 100644 src/main/worktree-removal-leftover.ts create mode 100644 src/main/worktree-removal-listing.ts create mode 100644 src/main/worktree-removal-table.ts diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index f7658f9c47f3..cd57a5696172 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -20,7 +20,7 @@ import type { } from './worktree-operation-options' import { gitExecOptions, resolveWorktreeAddTimeoutMs } from './worktree-operation-options' import { bumpWorktreeScanGeneration } from './worktree-scan-cache' -import { assertNoPendingWorktreeRemovalConflict } from '../worktree-background-removal' +import { assertNoPendingWorktreeRemovalConflict } from '../worktree-removal-table' export type WorktreeAddBaseContext = AddWorktreeResult & { effectiveBase: string diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index 7cd2e296ffd4..f9fb08676ff8 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -50,6 +50,7 @@ export function mergeWorktree( isBare: git.isBare, ...(git.isSparse === true ? { isSparse: true } : {}), isMainWorktree: git.isMainWorktree, + ...(git.removalError ? { removalError: git.removalError } : {}), // Automatic labels follow the live branch; persisted values are only authoritative when pinned. displayName: meta?.displayNameIsPinned === false diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 0bfb8951b8c8..c61bcb85eea6 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -170,7 +170,7 @@ import { retireGeneratedWorktreeName } from '../worktree-name-retirement' import { createRetiredNameLookup } from '../../shared/worktree/retired-name-registry' -import { findPendingWorktreeRemovalConflict } from '../worktree-background-removal' +import { findPendingWorktreeRemovalConflict } from '../worktree-removal-table' const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000 const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512 diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts new file mode 100644 index 000000000000..d4d1b51c6c66 --- /dev/null +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -0,0 +1,215 @@ +// Desktop IPC for a delete that failed after Git dropped the registration: the leftover stays in +// `worktrees:list` with the error, Delete retries it, and forget ends it. Git and the disk are +// mocked; runtime-failed-local-worktree-removal.test.ts runs the real thing. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + killAllProcessesForWorktreeMock, + listWorktreesMock, + removeWorktreeMock +} from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { mockKnownFeatureWorktree } from './worktrees-test-fixtures' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { Worktree } from '../../shared/worktree/types' +import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' +import type * as WorktreeRemovalModule from '../git/worktree-removal' +import type * as WorktreeRemovalTable from '../worktree-removal-table' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests, + startBackgroundWorktreeRemoval +} from '../worktree-background-removal' + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('../providers/ssh-git-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() +) +vi.mock('../providers/ssh-filesystem-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) + +vi.mock('../git/worktree-removal', async (importOriginal) => ({ + ...(await importOriginal()), + finishUnregisteredWorktreeRemoval: vi.fn(async () => ({})) +})) +// The leftover is on disk and is the removed checkout's own (no `.git` left). +vi.mock('../worktree-removal-table', async (importOriginal) => ({ + ...(await importOriginal()), + worktreeCheckoutExists: vi.fn(async () => true) +})) +vi.mock('../worktree-removal-leftover', () => ({ + isUnregisteredRemovalLeftover: vi.fn(async () => true) +})) + +const featureId = 'repo-1::/workspace/feature-wt' +const GIT_ERROR = "error: failed to delete '/workspace/feature-wt': Operation not permitted" + +function remove(args: Record): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktrees:remove resolves a RemoveWorktreeResult. + return handlers['worktrees:remove'](null, args) as Promise +} + +async function listFeature(): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktrees:list resolves the repo's Worktree rows. + const rows = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as Worktree[] + return rows.find((row) => row.id === featureId) +} + +/** Git fails partway and drops the registration, as `git worktree remove --force` does. */ +async function failAfterGitDroppedIt(): Promise { + const [main, feature] = mockKnownFeatureWorktree() + const result = startBackgroundWorktreeRemoval({ + removal: { + worktreeId: featureId, + repoId: 'repo-1', + repoPath: '/workspace/repo', + worktree: feature, + deleteBranch: true, + force: false + }, + run: async () => { + listWorktreesMock.mockResolvedValue([main]) + throw new Error(GIT_ERROR) + }, + publish: () => {} + }) + await expect(result).rejects.toThrow(GIT_ERROR) + await _settlePendingWorktreeRemovalsForTests() +} + +describe('a failed delete Git no longer registers, over desktop IPC', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + setupWorktreeHandlers() + }) + + afterEach(() => { + _resetPendingWorktreeRemovalsForTests() + vi.mocked(finishUnregisteredWorktreeRemoval).mockClear() + }) + + it('stays in the listing with the error instead of vanishing', async () => { + await failAfterGitDroppedIt() + + const row = await listFeature() + expect(row).toMatchObject({ path: '/workspace/feature-wt', removalError: GIT_ERROR }) + expect(row?.removing).toBeUndefined() + }) + + it('Delete runs the recorded removal again: teardown, leftover, branch and metadata', async () => { + await failAfterGitDroppedIt() + killAllProcessesForWorktreeMock.mockClear() + + await expect(remove({ worktreeId: featureId })).resolves.not.toHaveProperty('removing') + + // Git has no registration to delete by; the leftover goes through Orca's own delete. + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(finishUnregisteredWorktreeRemoval).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-wt', + { name: 'feature', head: 'feature' }, + {} + ) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith( + featureId, + expect.objectContaining({ requirePhysicalStop: true }) + ) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(featureId, 'local') + expect(await listFeature()).toBeUndefined() + }) + + it('keeps the row with the new error when the retry fails again', async () => { + await failAfterGitDroppedIt() + vi.mocked(finishUnregisteredWorktreeRemoval).mockRejectedValueOnce(new Error('EPERM again')) + + await expect(remove({ worktreeId: featureId })).rejects.toThrow('EPERM again') + await _settlePendingWorktreeRemovalsForTests() + + expect(await listFeature()).toMatchObject({ removalError: 'EPERM again' }) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('forget drops it from Orca without deleting anything', async () => { + await failAfterGitDroppedIt() + + await handlers['worktrees:forgetLocal'](null, { worktreeId: featureId }) + + expect(await listFeature()).toBeUndefined() + expect(finishUnregisteredWorktreeRemoval).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 8bf5d924b2ce..e63d024803b5 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -1,7 +1,8 @@ import { projectPendingWorktreeRemovals, - snapshotPendingWorktreeRemovals -} from '../../../worktree-background-removal' + snapshotPendingWorktreeRemovals, + withUnregisteredRemovalCheckouts +} from '../../../worktree-removal-listing' import { getRepoExecutionHostId, getSshTargetIdForExecutionHost @@ -149,6 +150,9 @@ export async function listDetectedWorktreesForCapturedRepo( return abortedResult() ?? null } const { gitWorktrees, fresh: freshScan, sideEffectToken, metadataPrune, hygieneDue } = scan + const localRows = connectionId + ? gitWorktrees + : await withUnregisteredRemovalCheckouts(repo.id, gitWorktrees) const aborted = abortedResult() if (aborted) { return aborted @@ -193,7 +197,7 @@ export async function listDetectedWorktreesForCapturedRepo( ? buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta) : // Why always marked: the desktop renderer ships with this main process. projectPendingWorktreeRemovals( - buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta), + buildDetectedGitWorktrees(store, repo, localRows, allMeta), (worktree) => worktree.id, true, pendingAtScan diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index c09c1224e88a..1473ab31bd84 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -36,8 +36,9 @@ import type { Worktree } from '../../../../shared/worktree/types' import { projectPendingWorktreeRemovals, snapshotPendingWorktreeRemovals, + withUnregisteredRemovalCheckouts, type PendingWorktreeRemovals -} from '../../../worktree-background-removal' +} from '../../../worktree-removal-listing' import { getLocalWorktreeScanGeneration } from '../../../local-worktree-scan-generation' import { getRegisteredWorktreeRootsRevision } from '../../registered-worktree-roots-cache' @@ -167,7 +168,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) const metadata = metadataForRepo(repo) - const worktrees = buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) + const rows = connectionId + ? gitWorktrees + : await withUnregisteredRemovalCheckouts(repo.id, gitWorktrees) + const worktrees = buildDetectedGitWorktrees(store, repo, rows, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) return connectionId ? worktrees : markLocalWorktreesUnderRemoval(worktrees, pendingAtScan) @@ -261,7 +265,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) const metadata = allMeta ?? readAllWorktreeMetaForRepo(store, repo) - const worktrees = buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) + const rows = connectionId + ? gitWorktrees + : await withUnregisteredRemovalCheckouts(repo.id, gitWorktrees) + const worktrees = buildDetectedGitWorktrees(store, repo, rows, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) return connectionId ? worktrees : markLocalWorktreesUnderRemoval(worktrees, pendingAtScan) diff --git a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts index 0c97dda68bd4..90416804fb17 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts @@ -35,6 +35,7 @@ import { preservedBranchCleanupByScope } from './preserved-branch-cleanup' import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { forgetFailedWorktreeRemoval } from '../../../worktree-removal-table' export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): void { const { mainWindow, store, runtime, worktreeRemovalsInFlight } = context @@ -117,6 +118,9 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi }) runtime.clearOptimisticReconcileToken(args.worktreeId) + if (!ownerHost || ownerHost.kind === 'local') { + forgetFailedWorktreeRemoval(args.worktreeId) + } // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. removeWorktreeMetadataAndTransientState( store, diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index d91743b01850..969167458036 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -17,6 +17,7 @@ import { waitForPendingWorktreeRemoval } from '../../../worktree-background-removal' import { runSerializedWorktreeRemovalAcceptance } from '../../../worktree-removal-acceptance-queue' +import { retryFailedLocalWorktreeRemoval } from './retry-failed-local-worktree-removal' export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { const { store, options, worktreeRemovalsInFlight } = context @@ -31,8 +32,11 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo } // The resolved repo supplies host ownership when legacy callers omit args.hostId. const removalHostId = getRepoExecutionHostId(repo) - // Why: a retry or a second window asking while Git still deletes joins that removal. - const pending = waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) + // Why: a retry or a second window asking while Git still deletes joins that removal, and + // Delete on the leftover of one that failed after Git dropped it runs that removal again. + const pending = + waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) ?? + retryFailedLocalWorktreeRemoval(context, args, removalHostId) if (pending) { return { ...(await pending), catalogVersion: getLocalWorktreeCatalogVersion(repoId) } } diff --git a/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts b/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts new file mode 100644 index 000000000000..a801d012062d --- /dev/null +++ b/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts @@ -0,0 +1,62 @@ +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import { retryFailedWorktreeRemoval } from '../../../worktree-background-removal' +import { interruptedLocalWorktreeRemovalJob } from '../../../runtime/runtime-interrupted-local-worktree-removal' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { + preserveBranchHeadFallback, + rememberPreservedBranchCleanupTarget +} from './preserved-branch-cleanup' +import { + removeWorktreeMetadataAndTransientState, + stopPtysForDestructiveWorktreeRemoval +} from './worktree-removal-ownership' + +/** + * Delete on the leftover of a local delete that failed after Git dropped the registration: runs the + * recorded removal again with this handler's bookkeeping. Undefined when there is none. + */ +export function retryFailedLocalWorktreeRemoval( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + removalHostId: ExecutionHostId +): Promise | undefined { + const { store, runtime, options } = context + return retryFailedWorktreeRemoval(args.worktreeId, removalHostId, (record) => + interruptedLocalWorktreeRemovalJob(record, { + store, + acquireWatcherRemoval: (path) => runtime.acquireFileWatcherRemoval(path), + closeWatchers: (path) => runtime.closeFileWatchersForRemoval(path), + stopPtys: () => + stopPtysForDestructiveWorktreeRemoval(runtime, record.worktreeId, { + allowUnverifiedStop: args.allowUnverifiedPtyStop + }), + preservedBranchCleanup: { + preserveHead: preserveBranchHeadFallback, + remember: (worktreeId, _hostId, result, fallbackHead, pushTarget) => + rememberPreservedBranchCleanupTarget( + worktreeId, + LOCAL_EXECUTION_HOST_ID, + result, + fallbackHead, + pushTarget + ) + }, + purge: ({ worktreeId }) => { + runtime.clearOptimisticReconcileToken(worktreeId) + removeWorktreeMetadataAndTransientState( + store, + worktreeId, + LOCAL_EXECUTION_HOST_ID, + args.snapshotPruneBatchId + ) + invalidateAuthorizedRootsCache() + }, + onRemoved: ({ worktreeId, worktreePath }) => + options?.onWorktreeLifecycle?.({ kind: 'removed', worktreeId, path: worktreePath }), + publish: (repoId) => runtime.publishWorktreeRemovalChange(repoId) + }) + ) +} diff --git a/src/main/repo-maintenance-idle-gate.ts b/src/main/repo-maintenance-idle-gate.ts index dec9acb549fb..7bacf6f7ccaa 100644 --- a/src/main/repo-maintenance-idle-gate.ts +++ b/src/main/repo-maintenance-idle-gate.ts @@ -6,7 +6,7 @@ import { } from './git/local-repo-ref-maintenance' import { hasWorktreeRemovalsInFlight } from './ipc/worktrees/worktree-ipc-context' import { hasPendingWorktreeCreatePreparations } from './worktree-create-preparation' -import { hasPendingWorktreeRemovals } from './worktree-background-removal' +import { hasPendingWorktreeRemovals } from './worktree-removal-table' /** * The app-wide "not now" answer for idle repo maintenance. diff --git a/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts b/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts index 152ef547889a..a6b729b465ef 100644 --- a/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts +++ b/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts @@ -20,7 +20,12 @@ import type { Repo } from '../../shared/repo-types' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' -import { getRepoExecutionHostId, getRepoSshConnectionId } from '../../shared/execution-host' +import { + getRepoExecutionHostId, + getRepoSshConnectionId, + LOCAL_EXECUTION_HOST_ID +} from '../../shared/execution-host' +import { withUnregisteredRemovalCheckouts } from '../worktree-removal-listing' import type { RuntimeWorktreeScanCache } from './orca-runtime-core' import { resolveWorktreeScanCacheTtlMs } from './runtime-worktree-scan-cache' @@ -116,7 +121,7 @@ export class OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget extends return { store, scanRepo: (repo, projectRuntimeByRepoId) => - this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId), + this.listRepoWorktreesForListing(repo, projectRuntimeByRepoId), listFolderWorkspaces: (repo, repoOwnerCount) => listRuntimeFolderWorkspaces(store, repo, repoOwnerCount) } @@ -132,6 +137,17 @@ export class OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget extends return await resolveScopedWorktreeIdRow(this.repoWorktreeRowDeps(), worktreeId, requiredHostId) } + /** The resolution scan plus the checkouts this host's removals own that Git no longer lists. */ + protected async listRepoWorktreesForListing( + repo: Repo, + projectRuntimeByRepoId?: ReadonlyMap + ): Promise { + const scan = await this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId) + return scan.ok && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID + ? { ok: true, worktrees: await withUnregisteredRemovalCheckouts(repo.id, scan.worktrees) } + : scan + } + protected async listRepoWorktreesForResolution( repo: Repo, projectRuntimeByRepoId?: ReadonlyMap diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index dc065bd82248..ea0eaa2be198 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -6,10 +6,7 @@ import { } from '../worktree-removal-repo-owner' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' -import { - finishAcceptedWorktreeRemoval, - waitForPendingWorktreeRemoval -} from '../worktree-background-removal' +import { finishAcceptedWorktreeRemoval } from '../worktree-background-removal' import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' import { getRuntimeWorktreeRemovalOptionsKey, @@ -54,7 +51,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM const cleanupHostId = parseExecutionHostId(hostId)?.id const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId) // Why: a retry or a second client asking while Git still deletes joins that removal. - const pending = waitForPendingWorktreeRemoval(removalTarget.id, cleanupHostId) + const pending = this.joinOrRetryWorktreeRemoval(removalTarget.id, options) if (pending) { return options.waitForBackgroundRemoval ? await pending : { removing: true } } diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 7fef1bdf9db1..9dc345f48063 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -1,7 +1,10 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithRemoveManagedWorktree } from './orca-runtime-remove-managed-worktree' import type { ExecutionHostId } from '../../shared/execution-host' -import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' +import type { + RemoveManagedWorktreeOptions, + RuntimeWorktreeRemovalTarget +} from './runtime-worktree-selection' import { resolveRuntimeWorktreeRemovalTarget } from './runtime-worktree-removal-target' import type { RuntimeStore } from './runtime-store-contract' import { splitWorktreeId } from '../../shared/worktree/id' @@ -22,9 +25,14 @@ import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../shared/execution-host' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' -import { resumeInterruptedWorktreeRemovals } from '../worktree-background-removal' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import { + resumeInterruptedWorktreeRemovals, + retryFailedWorktreeRemoval, + waitForPendingWorktreeRemoval +} from '../worktree-background-removal' import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { @@ -49,20 +57,49 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith return } resumeInterruptedWorktreeRemovals((record) => + interruptedLocalWorktreeRemovalJob(record, this.localRemovalJobHost(store)) + ) + } + + /** + * The removal a request for this worktree waits on: the one still running, or the leftover of one + * that failed after Git dropped the registration, run again. + */ + protected joinOrRetryWorktreeRemoval( + worktreeId: string, + options: RemoveManagedWorktreeOptions + ): Promise | undefined { + const hostId = parseExecutionHostId(options.hostId)?.id + const store = this.store + const pending = waitForPendingWorktreeRemoval(worktreeId, hostId) + if (pending || !store) { + return pending + } + return retryFailedWorktreeRemoval(worktreeId, hostId, (record) => interruptedLocalWorktreeRemovalJob(record, { - store, - acquireWatcherRemoval: this.acquireFileWatcherRemoval, - closeWatchers: (path) => this.closeFileWatchersForRemoval(path), - preservedBranchCleanup: this.preservedBranchCleanup, - purge: ({ worktreeId, repoId }) => - this.purgeRemovedWorktree(store, worktreeId, repoId, LOCAL_EXECUTION_HOST_ID), - onRemoved: ({ worktreeId, worktreePath }) => - this.emitWorktreeLifecycle({ kind: 'removed', worktreeId, path: worktreePath }), - publish: (repoId) => this.publishWorktreeRemovalChange(repoId) + ...this.localRemovalJobHost(store), + stopPtys: () => + this.stopPtysForDestructiveWorktreeRemoval(record.worktreeId, { + allowUnverifiedStop: options.allowUnverifiedPtyStop === true + }) }) ) } + protected localRemovalJobHost(store: RuntimeStore) { + return { + store, + acquireWatcherRemoval: this.acquireFileWatcherRemoval, + closeWatchers: (path) => this.closeFileWatchersForRemoval(path), + preservedBranchCleanup: this.preservedBranchCleanup, + purge: ({ worktreeId, repoId }) => + this.purgeRemovedWorktree(store, worktreeId, repoId, LOCAL_EXECUTION_HOST_ID), + onRemoved: ({ worktreeId, worktreePath }) => + this.emitWorktreeLifecycle({ kind: 'removed', worktreeId, path: worktreePath }), + publish: (repoId) => this.publishWorktreeRemovalChange(repoId) + } + } + // Host state every removal path drops once Git has let go of the checkout. protected purgeRemovedWorktree( store: RuntimeStore, diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index a7175383df0d..75787230fbef 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -144,7 +144,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId listResolved: () => this.listResolvedWorktrees(), resolveRepo: (selector) => this.resolveRepoSelector(selector), selectRepos: (selector) => this.selectReposBySelector(selector), - scanRepo: (repo) => this.listRepoWorktreesForResolution(repo), + scanRepo: (repo) => this.listRepoWorktreesForListing(repo), listKnownHostIds: () => this.listKnownExecutionHostIds() }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts new file mode 100644 index 000000000000..9d9e32121910 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -0,0 +1,90 @@ +// A runtime Delete (paired desktop, web, mobile, CLI) on the leftover of a delete that failed after +// Git dropped the registration: the leftover is listed with its error and Delete runs the recorded +// removal again, instead of the leftover vanishing from every listing. +import { existsSync } from 'node:fs' +import { realpath } from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join, mkdir, mkdtemp, removeWorktree, rm, tmpdir } from '../orca-runtime-test-mocks.spec' +import { TEST_REPO_ID, TEST_REPO_PATH } from '../orca-runtime-test-fixtures.spec' +import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests, + loadWorktreeRemovalRecords +} from '../../worktree-background-removal' +import { + readWorktreeRemovalRecords, + writeWorktreeRemovalRecords +} from '../../worktree-removal-records' + +const FAILURE = "error: failed to delete 'node_modules/a/LICENSE': Operation not permitted" + +describe('runtime Delete on a failed delete’s leftover', () => { + let directory = '' + let leftover = '' + let leftoverId = '' + + beforeEach(async () => { + vi.clearAllMocks() + directory = await realpath(await mkdtemp(join(tmpdir(), 'orca-runtime-failed-removal-'))) + leftover = join(directory, 'feature') + leftoverId = `${TEST_REPO_ID}::${leftover}` + await mkdir(join(leftover, 'node_modules'), { recursive: true }) + await writeWorktreeRemovalRecords(directory, () => [ + { + worktreeId: leftoverId, + repoId: TEST_REPO_ID, + repoPath: TEST_REPO_PATH, + worktreePath: leftover, + branch: 'feature', + head: 'abc', + deleteBranch: true, + force: true, + requestedAt: 1, + failure: { message: FAILURE, failedAt: 2 } + } + ]) + await loadWorktreeRemovalRecords(directory) + }) + + afterEach(async () => { + _resetPendingWorktreeRemovalsForTests() + await rm(directory, { recursive: true, force: true }) + }) + + it('lists the leftover with its error, though Git no longer does', async () => { + const runtime = createWorktreeRemovalRuntime() + + const detected = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + + expect(detected.worktrees.find((row) => row.id === leftoverId)).toMatchObject({ + path: leftover, + removalError: FAILURE + }) + }) + + it('runs the recorded removal again, answering a client that cannot wait on acceptance', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtime = createWorktreeRemovalRuntime() + + await expect( + runtime.removeManagedWorktree(`id:${leftoverId}`, { waitForBackgroundRemoval: false }) + ).resolves.toEqual({ removing: true }) + await _settlePendingWorktreeRemovalsForTests() + + // Git has no registration left for it, so Orca deletes the leftover itself. + expect(removeWorktree).not.toHaveBeenCalled() + expect(existsSync(leftover)).toBe(false) + expect(await readWorktreeRemovalRecords(directory)).toEqual([]) + }) + + it('replies to a waiting client once the retry finishes', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtime = createWorktreeRemovalRuntime() + + await expect( + runtime.removeManagedWorktree(`id:${leftoverId}`, { waitForBackgroundRemoval: true }) + ).resolves.toEqual({}) + expect(existsSync(leftover)).toBe(false) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 070363fdb3fb..dca1d1895990 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -114,6 +114,7 @@ await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-02.s await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec') await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec') await import('./orca-runtime-tests/worktree-removal-archive-hook-gate.spec') +await import('./orca-runtime-tests/worktree-removal-failed-retry.spec') await import('./orca-runtime-tests/worktree-removal-execution-host.spec') await import('./orca-runtime-tests/targeting-and-resilience.spec') await import('./orca-runtime-tests/worktree-scan-cache-ttl.spec') diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index 082d0400f7c8..f4f3ba99cae8 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -5,7 +5,7 @@ import { projectWorktreeListRemovals, projectWorktreePsRemovals } from '../worktree-removal-marker-projection' -import { snapshotPendingWorktreeRemovals } from '../../../worktree-background-removal' +import { snapshotPendingWorktreeRemovals } from '../../../worktree-removal-listing' import { WorktreeDetectedListParams, WorktreeListParams, diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts index 4ce22b0a3600..0f3950161f4e 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts @@ -11,9 +11,9 @@ import type { } from '../../../shared/runtime-worktree-contracts' import { _resetPendingWorktreeRemovalsForTests, - snapshotPendingWorktreeRemovals, startBackgroundWorktreeRemoval } from '../../worktree-background-removal' +import { snapshotPendingWorktreeRemovals } from '../../worktree-removal-listing' import { projectWorktreeListRemovals, projectWorktreePsRemovals diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.ts index f4f13ca9c418..4e1b18a266cd 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.ts @@ -7,7 +7,7 @@ import type { import { projectPendingWorktreeRemovals, type PendingWorktreeRemovals -} from '../../worktree-background-removal' +} from '../../worktree-removal-listing' import type { RpcContext } from './core' // Why no in-process default: callers without negotiation (the CLI, host-side readers) print or act diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts new file mode 100644 index 000000000000..f76ec75c95a0 --- /dev/null +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -0,0 +1,282 @@ +// Real-Git coverage for a delete Git fails partway: `git worktree remove --force` drops the +// registration even when it cannot delete a file, so Orca must keep the leftover listed and +// retryable itself. macOS only: `chflags uchg` is the portable way to make a file undeletable for +// the file's owner; worktree-failed-removal.test.ts covers the same rules with Git mocked. +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import type { Store } from '../persistence' +import type * as HostTreeRemoval from '../host-tree-removal' +import { removeHostTree } from '../host-tree-removal' +import { listWorktreesStrict, removeWorktree } from '../git/worktree' +import { areWorktreePathsEqual } from '../git/worktree-path-comparison' +import { acquireWatcherRemovalGate, beginTerminalInstall } from '../ipc/watcher-removal-gate' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests, + loadWorktreeRemovalRecords, + resumeInterruptedWorktreeRemovals, + retryFailedWorktreeRemoval, + startBackgroundWorktreeRemoval, + waitForPendingWorktreeRemoval +} from '../worktree-background-removal' +import { withUnregisteredRemovalCheckouts } from '../worktree-removal-listing' +import { + readWorktreeRemovalRecords, + writeWorktreeRemovalRecords, + type WorktreeRemovalRecord +} from '../worktree-removal-records' +import { forgetFailedWorktreeRemoval } from '../worktree-removal-table' +import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' + +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: () => ({}) +})) +vi.mock('../host-tree-removal', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, removeHostTree: vi.fn(actual.removeHostTree) } +}) + +const execFileAsync = promisify(execFile) + +let scratchDir = '' +let recordsDir = '' +let repoPath = '' +let worktreePath = '' +let lockedFile = '' +let worktreeId = '' +let repo: Repo + +async function git(args: string[], cwd = repoPath): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +async function isRegistered(path: string): Promise { + return (await listWorktreesStrict(repoPath)).some((worktree) => + areWorktreePathsEqual(worktree.path, path) + ) +} + +async function setImmutable(on: boolean, path = lockedFile): Promise { + await execFileAsync('chflags', on ? ['uchg', path] : ['-R', 'nouchg', path]) +} + +async function listedRows(): Promise<{ path: string; removalError?: string }[]> { + return (await withUnregisteredRemovalCheckouts(repo.id, await listWorktreesStrict(repoPath))) + .filter((row) => !row.isMainWorktree) + .map(({ path, removalError }) => ({ path, ...(removalError ? { removalError } : {}) })) +} + +function jobHost(purged: string[], stopPtys = vi.fn(async () => {})) { + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the finish reads only repos and worktree metadata from the store here; git options are mocked and no push target is set. + store: { + getRepo: (id: string) => (id === repo.id ? repo : undefined), + getRepos: () => [repo], + getWorktreeMeta: () => undefined + } as unknown as Store, + acquireWatcherRemoval: async (path: string) => { + const gate = acquireWatcherRemovalGate(path) + return { finish: async () => gate.release() } + }, + closeWatchers: async () => {}, + stopPtys, + preservedBranchCleanup: { preserveHead: (result) => result ?? {}, remember: vi.fn() }, + purge: ({ worktreeId: id }: WorktreeRemovalRecord) => purged.push(id), + onRemoved: () => {}, + publish: () => {} + } satisfies Parameters[1] +} + +/** A delete a quit interrupted, finished at the next start, where Git fails on the locked file. */ +async function failStartupFinish(): Promise { + const record: WorktreeRemovalRecord = { + worktreeId, + repoId: repo.id, + repoPath, + worktreePath, + branch: 'feature', + head: (await git(['rev-parse', 'feature'])).trim(), + deleteBranch: true, + force: true, + requestedAt: 1 + } + await writeWorktreeRemovalRecords(recordsDir, () => [record]) + await loadWorktreeRemovalRecords(recordsDir) + const joined = waitForPendingWorktreeRemoval(worktreeId)! + resumeInterruptedWorktreeRemovals((interrupted) => + interruptedLocalWorktreeRemovalJob(interrupted, jobHost([])) + ) + const error = await joined.then( + () => undefined, + (reason: unknown) => reason + ) + await _settlePendingWorktreeRemovalsForTests() + return error +} + +/** The same delete in session: the job runs Git's `worktree remove --force`, as Delete's does. */ +async function failInSession(): Promise { + const error = await startBackgroundWorktreeRemoval({ + removal: { + worktreeId, + repoId: repo.id, + repoPath, + worktree: { path: worktreePath, branch: 'refs/heads/feature', head: 'abc' }, + deleteBranch: true, + force: true + }, + run: () => removeWorktree(repoPath, worktreePath, true), + publish: () => {} + }).then( + () => undefined, + (reason: unknown) => reason + ) + await _settlePendingWorktreeRemovalsForTests() + return error +} + +describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails partway', () => { + beforeEach(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-failed-removal-'))) + recordsDir = join(scratchDir, 'profile') + repoPath = join(scratchDir, 'repo') + worktreePath = join(scratchDir, 'workspaces', 'feature') + worktreeId = `repo-1::${worktreePath}` + await mkdir(recordsDir, { recursive: true }) + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q']) + await git(['config', 'user.email', 'removal@example.invalid']) + await git(['config', 'user.name', 'Worktree Removal']) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A']) + await git(['commit', '-qm', 'seed']) + await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature']) + lockedFile = join(worktreePath, 'node_modules', 'a', 'LICENSE') + await mkdir(join(worktreePath, 'node_modules', 'a'), { recursive: true }) + await writeFile(lockedFile, 'MIT\n') + await setImmutable(true) + repo = { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '', addedAt: 0 } + await loadWorktreeRemovalRecords(recordsDir) + }) + + afterEach(async () => { + _resetPendingWorktreeRemovalsForTests() + vi.mocked(removeHostTree).mockClear() + vi.restoreAllMocks() + await setImmutable(false, scratchDir) + expect((await execFileAsync('find', [scratchDir, '-flags', '+uchg'])).stdout).toBe('') + await removeTree(scratchDir) + }) + + it('keeps the leftover listed with Git’s error after the startup finish fails', async () => { + const error = await failStartupFinish() + + expect(String(error)).toMatch(/Operation not permitted/) + // What Git left: no registration, but the checkout, the branch and Orca's record. + expect(await isRegistered(worktreePath)).toBe(false) + expect(existsSync(lockedFile)).toBe(true) + expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(await listedRows()).toEqual([ + { path: worktreePath, removalError: expect.stringMatching(/Operation not permitted/) } + ]) + // The leftover is not fenced: terminals may open in it while it waits for the user. + beginTerminalInstall(worktreePath)() + }) + + it('keeps the leftover listed with Git’s error after an in-session delete fails', async () => { + const error = await failInSession() + + expect(String(error)).toMatch(/Operation not permitted/) + expect(await isRegistered(worktreePath)).toBe(false) + expect(await listedRows()).toEqual([ + { path: worktreePath, removalError: expect.stringMatching(/Operation not permitted/) } + ]) + }) + + it('does not retry it at the next start', async () => { + await failStartupFinish() + _resetPendingWorktreeRemovalsForTests() + await loadWorktreeRemovalRecords(recordsDir) + const jobFor = vi.fn() + + resumeInterruptedWorktreeRemovals(jobFor) + + expect(jobFor).not.toHaveBeenCalled() + expect(waitForPendingWorktreeRemoval(worktreeId)).toBeUndefined() + expect(existsSync(lockedFile)).toBe(true) + expect(await listedRows()).toHaveLength(1) + }) + + it('Delete retries it once the file can be deleted: files, branch, metadata and record go', async () => { + await failInSession() + await setImmutable(false) + const purged: string[] = [] + const stopPtys = vi.fn(async () => {}) + + const result = await retryFailedWorktreeRemoval(worktreeId, 'local', (record) => + interruptedLocalWorktreeRemovalJob(record, jobHost(purged, stopPtys)) + ) + await _settlePendingWorktreeRemovalsForTests() + + expect(result).toEqual({}) + expect(stopPtys).toHaveBeenCalledTimes(1) + // Git has no registration left to delete by, so Orca deletes the leftover itself. + expect(removeHostTree).toHaveBeenCalledWith(worktreePath) + expect(existsSync(worktreePath)).toBe(false) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([worktreeId]) + expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + expect(await listedRows()).toEqual([]) + }) + + it('forgetting the workspace ends the record and keeps the files', async () => { + await failInSession() + + forgetFailedWorktreeRemoval(worktreeId) + + await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([])) + expect(existsSync(lockedFile)).toBe(true) + expect(await listedRows()).toEqual([]) + }) + + it('the record ends once the checkout is deleted outside Orca', async () => { + await failInSession() + await setImmutable(false) + await rm(worktreePath, { recursive: true }) + + expect(await listedRows()).toEqual([]) + await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([])) + }) + + it('never deletes a different checkout created at the path since', async () => { + await failInSession() + await setImmutable(false) + await rm(worktreePath, { recursive: true }) + await mkdir(worktreePath) + await git(['init', '-q'], worktreePath) + await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') + const purged: string[] = [] + + // Delete before any listing noticed: the retry refuses and lets the record go. + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => + interruptedLocalWorktreeRemovalJob(record, jobHost(purged)) + ) + await expect(retried).rejects.toThrow(/A different checkout is now at/) + await _settlePendingWorktreeRemovalsForTests() + + expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + expect(await listedRows()).toEqual([]) + }) +}) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index ca50faf3b8f1..e093190055f7 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -18,6 +18,7 @@ import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' +import { isUnregisteredRemovalLeftover } from '../worktree-removal-leftover' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { WorktreeRemovalRecord } from '../worktree-removal-records' import { @@ -31,6 +32,8 @@ type InterruptedWorktreeRemovalHost = { acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise }> closeWatchers: (path: string) => Promise preservedBranchCleanup: Pick + /** A retry's teardown: terminals may have opened in the leftover since the failed delete. */ + stopPtys?: () => Promise /** Drops the worktree's host state (metadata, history, caches), as every removal path does. */ purge: (record: WorktreeRemovalRecord) => void onRemoved: (record: WorktreeRemovalRecord) => void @@ -61,6 +64,7 @@ export function interruptedLocalWorktreeRemovalJob( return host.acquireWatcherRemoval(path) }, closeWatchers: host.closeWatchers, + stopPtys: host.stopPtys, preserveBranchHead: (result, fallbackHead) => host.preservedBranchCleanup.preserveHead(result, fallbackHead), // remember() clears the cleanup target when no branch was preserved. @@ -89,6 +93,7 @@ type InterruptedLocalWorktreeRemovalArgs = Pick< store: Store record: WorktreeRemovalRecord acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise }> + stopPtys?: () => Promise stopSignal: AbortSignal } @@ -138,9 +143,13 @@ async function finishInterruptedLocalWorktreeRemoval( } const gitLink = await readCheckoutGitLink(record.worktreePath) // Why: the finish forces, so a checkout created at this path since the quit must not be taken. - // Git deletes the checkout, `.git` included, before it drops the registration, so a `.git` at an - // unregistered path belongs to a new checkout. - if (deletable ? !isRecordedCheckout(deletable, record) : gitLink === 'present') { + // At an unregistered path, only a `.git` naming the admin entry Git removed is this checkout's + // own leftover (Git drops the registration even when its delete fails partway). + if ( + deletable + ? !isRecordedCheckout(deletable, record) + : !(await isUnregisteredRemovalLeftover(repo.path, record.worktreePath)) + ) { throw new Error( `A different checkout is now at ${record.worktreePath}; Orca left it in place. Delete it again to remove it.` ) @@ -153,6 +162,14 @@ async function finishInterruptedLocalWorktreeRemoval( gitCanRemove = await restoreMissingWorktreeGitFile(repo.path, deletable.path, localOptions) } const gate = await args.acquireWatcherRemoval(record.worktreePath) + if (args.stopPtys) { + try { + await args.stopPtys() + } catch (error) { + await gate.finish(false) + throw error + } + } if (deletable && gitCanRemove) { return finishRuntimeLocalWorktreeRemoval(finishArgs, deletable, gate, args.stopSignal) } diff --git a/src/main/runtime/runtime-local-worktree-create-candidate.ts b/src/main/runtime/runtime-local-worktree-create-candidate.ts index 7af0f8b99104..169e62f5a57f 100644 --- a/src/main/runtime/runtime-local-worktree-create-candidate.ts +++ b/src/main/runtime/runtime-local-worktree-create-candidate.ts @@ -32,7 +32,7 @@ import { resolveCreateBranchName } from './runtime-worktree-create-git' import { runtimePathExists } from './runtime-worktree-filesystem' -import { findPendingWorktreeRemovalConflict } from '../worktree-background-removal' +import { findPendingWorktreeRemovalConflict } from '../worktree-removal-table' import type { RuntimeStore } from './runtime-store-contract' import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' diff --git a/src/main/runtime/runtime-worktree-ps-summaries.test.ts b/src/main/runtime/runtime-worktree-ps-summaries.test.ts index a588c7c2f003..91054cd80462 100644 --- a/src/main/runtime/runtime-worktree-ps-summaries.test.ts +++ b/src/main/runtime/runtime-worktree-ps-summaries.test.ts @@ -34,4 +34,37 @@ describe('buildRuntimeWorktreePsSummaries', () => { expect(summary?.hostId).toBe('ssh:persisted-host') }) + + it('carries the error of a failed delete the host still lists', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these fields for a git row. + const worktree = { + id: 'repo-1::/workspace/app', + repoId: 'repo-1', + path: '/workspace/app', + branch: 'feature', + isArchived: false, + isMainWorktree: false, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + lastActivityAt: 0, + removalError: 'Operation not permitted' + } as unknown as ResolvedWorktree + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these store members. + const store = { + getRepos: () => [], + getWorktreeMeta: () => undefined, + getAllWorktreeMeta: () => ({}), + getFolderWorkspaces: () => [], + getProjectGroups: () => [] + } as unknown as RuntimeStore + + const summary = buildRuntimeWorktreePsSummaries({ + store, + resolvedWorktrees: [worktree], + platformByRepoId: new Map() + }).get(worktree.id) + + expect(summary?.removalError).toBe('Operation not permitted') + }) }) diff --git a/src/main/runtime/runtime-worktree-ps-summaries.ts b/src/main/runtime/runtime-worktree-ps-summaries.ts index 2e52a5c29e1b..1d5a161a8a5e 100644 --- a/src/main/runtime/runtime-worktree-ps-summaries.ts +++ b/src/main/runtime/runtime-worktree-ps-summaries.ts @@ -42,6 +42,7 @@ export function buildRuntimeWorktreePsSummaries(args: { isArchived: worktree.isArchived, isMainWorktree: worktree.isMainWorktree, hasHostSidebarActivity: false, + ...(worktree.removalError ? { removalError: worktree.removalError } : {}), ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), ...(lineage?.worktreeInstanceId !== undefined ? { lineageWorktreeInstanceId: lineage.worktreeInstanceId } diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index 6707acfe271d..726240fefb1f 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -6,13 +6,15 @@ import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, loadWorktreeRemovalRecords, - projectPendingWorktreeRemovals, resumeInterruptedWorktreeRemovals, - snapshotPendingWorktreeRemovals, startBackgroundWorktreeRemoval, stopBackgroundWorktreeRemovals, waitForPendingWorktreeRemoval } from './worktree-background-removal' +import { + projectPendingWorktreeRemovals, + snapshotPendingWorktreeRemovals +} from './worktree-removal-listing' import type * as WorktreeRemovalRecords from './worktree-removal-records' import { readWorktreeRemovalRecords, diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts index 2ddc1952e8eb..1517954f25cd 100644 --- a/src/main/worktree-background-removal.test.ts +++ b/src/main/worktree-background-removal.test.ts @@ -3,14 +3,16 @@ import type { ExecutionHostId } from '../shared/execution-host' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, - assertNoPendingWorktreeRemovalConflict, finishAcceptedWorktreeRemoval, - projectPendingWorktreeRemovals, removesInBackground, - snapshotPendingWorktreeRemovals, startBackgroundWorktreeRemoval, waitForPendingWorktreeRemoval } from './worktree-background-removal' +import { + projectPendingWorktreeRemovals, + snapshotPendingWorktreeRemovals +} from './worktree-removal-listing' +import { assertNoPendingWorktreeRemovalConflict } from './worktree-removal-table' const removal = { worktreeId: 'repo-1::/work/feature', diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index be6bb365d5ae..3488c8df3245 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -1,15 +1,22 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' import type { RemoveWorktreeResult } from '../shared/worktree/create-types' import type { GitWorktreeInfo } from '../shared/worktree/types' +import { listWorktreesStrict } from './git/worktree' import { normalizeLocalBranchRef } from './git/worktree-operation-options' import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { acquireWatcherRemovalGate, type WatcherRemovalGate } from './ipc/watcher-removal-gate' +import { runWorktreeChangeInvalidators } from './ipc/worktree-change-invalidators' import { parseWslPath } from './wsl' +import { readWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktree-removal-records' +import { isUnregisteredRemovalLeftover } from './worktree-removal-leftover' import { - readWorktreeRemovalRecords, - writeWorktreeRemovalRecords, - type WorktreeRemovalRecord -} from './worktree-removal-records' + failedWorktreeRemovals, + finishedWorktreeRemovals, + pendingWorktreeRemovals, + persistWorktreeRemovalRecords, + setWorktreeRemovalRecordsDirectory, + worktreeCheckoutExists +} from './worktree-removal-table' export type BackgroundWorktreeRemovalJob = { /** `stopSignal` aborts on an orderly quit; pass it only to the checkout delete. */ @@ -18,36 +25,36 @@ export type BackgroundWorktreeRemovalJob = { publish: () => void } -/** The removals pending when a listing began to read Git. */ -export type PendingWorktreeRemovals = ReadonlyMap - type RemovalSettlement = { result: Promise resolve: (result: RemoveWorktreeResult) => void reject: (error: unknown) => void } -// The accepted removals, mirrored to disk on every change; listings and joins read only this. -const pendingByWorktreeId = new Map() const jobsByWorktreeId = new Map>() // What every request for a pending removal waits on: the first one and any that join it. const settlementsByWorktreeId = new Map() const stopControllers = new Set() // Loaded removals' terminal/watcher fences, held until the resumed job takes its own gate. const startupFencesByWorktreeId = new Map() -// Why weak: a listing that read Git before a delete finished holds the record until it replies. -const removedRecords = new WeakSet() -const NO_PENDING_REMOVALS: PendingWorktreeRemovals = new Map() -let recordsDirectory: string | null = null /** * Loads removals a quit or crash interrupted, so listings mark them before the first paint and * session restore cannot open a terminal or watcher in a half-deleted checkout before the resume. */ export async function loadWorktreeRemovalRecords(directory: string): Promise { - recordsDirectory = directory + setWorktreeRemovalRecordsDirectory(directory) + let droppedFailure = false for (const record of await readWorktreeRemovalRecords(directory)) { - if (!pendingByWorktreeId.has(record.worktreeId)) { + if (record.failure) { + if (await worktreeCheckoutExists(record.worktreePath)) { + failedWorktreeRemovals.set(record.worktreeId, record) + } else { + droppedFailure = true + } + continue + } + if (!pendingWorktreeRemovals.has(record.worktreeId)) { addPendingRemoval(record) try { startupFencesByWorktreeId.set( @@ -59,6 +66,9 @@ export async function loadWorktreeRemovalRecords(directory: string): Promise {}) const settlement = { result, resolve, reject } - pendingByWorktreeId.set(record.worktreeId, record) + // A new removal of the same workspace supersedes its failed one. + failedWorktreeRemovals.delete(record.worktreeId) + pendingWorktreeRemovals.set(record.worktreeId, record) settlementsByWorktreeId.set(record.worktreeId, settlement) return settlement } -function persistRecords(): Promise { - if (!recordsDirectory) { - return Promise.resolve() - } - return writeWorktreeRemovalRecords(recordsDirectory, () => [ - ...pendingByWorktreeId.values() - ]).catch((error: unknown) => { - // Why: bookkeeping must not gate the delete; a lost write only costs resuming it after a quit. - console.warn('[worktrees] failed to persist worktree removal records', error) - }) -} - /** * The result of the removal this host is running for the worktree, for a request that joins it. * Only this host's local checkouts are removed in the background. @@ -126,41 +126,6 @@ export function removesInBackground( return !options.wslDistro && !parseWslPath(worktreePath) } -export function hasPendingWorktreeRemovals(): boolean { - return pendingByWorktreeId.size > 0 -} - -export function findPendingWorktreeRemovalConflict( - repoPath: string, - target: { worktreePath?: string; branch?: string } -): WorktreeRemovalRecord | undefined { - const branch = target.branch?.replace(/^refs\/heads\//, '') - for (const removal of pendingByWorktreeId.values()) { - if (!areWorktreePathsEqual(removal.repoPath, repoPath)) { - continue - } - if ( - (target.worktreePath && areWorktreePathsEqual(removal.worktreePath, target.worktreePath)) || - (branch && removal.branch === branch) - ) { - return removal - } - } - return undefined -} - -export function assertNoPendingWorktreeRemovalConflict( - repoPath: string, - target: { worktreePath?: string; branch?: string } -): void { - const removal = findPendingWorktreeRemovalConflict(repoPath, target) - if (removal) { - throw new Error( - `Orca is still deleting the workspace at ${removal.worktreePath}. Cleanup is pending; try again shortly.` - ) - } -} - /** * Records an accepted removal and runs its delete detached from the request that asked for it, so * the delete finishes even when that request times out or its client goes away. Resolves with the @@ -183,16 +148,40 @@ export function startBackgroundWorktreeRemoval( requestedAt: Date.now() } const settlement = addPendingRemoval(record) - runBackgroundWorktreeRemoval(record, args, persistRecords()) + runBackgroundWorktreeRemoval(record, args, persistWorktreeRemovalRecords()) publishSafely(args.publish) return settlement.result } +/** + * Delete on a row whose earlier delete failed after Git dropped its registration: runs the recorded + * removal again, with the choices the user made the first time. Undefined when there is none. + */ +export function retryFailedWorktreeRemoval( + worktreeId: string, + hostId: ExecutionHostId | undefined, + jobFor: (record: WorktreeRemovalRecord) => BackgroundWorktreeRemovalJob +): Promise | undefined { + const failed = + (hostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID + ? failedWorktreeRemovals.get(worktreeId) + : undefined + if (!failed) { + return undefined + } + const { failure: _failure, ...record } = failed + const settlement = addPendingRemoval(record) + const job = jobFor(record) + runBackgroundWorktreeRemoval(record, job, persistWorktreeRemovalRecords()) + publishSafely(job.publish) + return settlement.result +} + /** Runs the same delete again for every record a quit or crash left without a running job. */ export function resumeInterruptedWorktreeRemovals( jobFor: (record: WorktreeRemovalRecord) => BackgroundWorktreeRemovalJob ): void { - for (const record of pendingByWorktreeId.values()) { + for (const record of pendingWorktreeRemovals.values()) { if (!jobsByWorktreeId.has(record.worktreeId)) { runBackgroundWorktreeRemoval(record, jobFor(record), Promise.resolve()) } @@ -237,12 +226,13 @@ async function settleBackgroundWorktreeRemoval( ): Promise { await waitForRecordWrite(record, recorded) let settle: (settlement: RemovalSettlement) => void + let failure: WorktreeRemovalRecord['failure'] try { if (stopSignal.aborted) { return } const result = await job.run(stopSignal) - removedRecords.add(record) + finishedWorktreeRemovals.add(record) settle = (settlement) => settlement.resolve(result) } catch (error) { if (stopSignal.aborted) { @@ -251,15 +241,29 @@ async function settleBackgroundWorktreeRemoval( } console.warn(`[worktrees] background removal of ${record.worktreePath} failed`, error) settle = (settlement) => settlement.reject(error) + // Why: Git drops the registration even when it fails to delete the checkout, and Orca lists + // workspaces from Git, so without the record the leftover would vanish with no way to retry. + if (await isCheckoutLeftUnregistered(record)) { + failure = { + message: error instanceof Error ? error.message : String(error), + failedAt: Date.now() + } + } } finally { // A resumed job that ended before taking its own gate still holds the fence loading gave it. releaseStartupRemovalFence(record.worktreeId) } - // Why clear on failure too: the row returns live and retryable instead of retrying unseen. - const cleared = pendingByWorktreeId.get(record.worktreeId) === record + // Why clear on failure too: the row returns with its error and Delete retries it; nothing + // retries unseen. + const cleared = pendingWorktreeRemovals.get(record.worktreeId) === record if (cleared) { - pendingByWorktreeId.delete(record.worktreeId) + pendingWorktreeRemovals.delete(record.worktreeId) settlementsByWorktreeId.delete(record.worktreeId) + if (failure) { + failedWorktreeRemovals.set(record.worktreeId, { ...record, failure }) + // Git's catalog changed under a failed delete; cached scans still list the checkout. + runWorktreeChangeInvalidators(record.repoId) + } } // Why this run's own settlement: desktop IPC and runtime RPC coalesce separately, so a concurrent // removal can replace the record, and the request waiting on this delete must still get its reply. @@ -270,7 +274,24 @@ async function settleBackgroundWorktreeRemoval( // re-runs a finish that re-derives what is left from Git. publishSafely(job.publish) if (cleared) { - await persistRecords() + await persistWorktreeRemovalRecords() + } +} + +async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promise { + if (!(await worktreeCheckoutExists(record.worktreePath))) { + return false + } + try { + const registered = await listWorktreesStrict(record.repoPath) + return ( + !registered.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath)) && + (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath)) + ) + } catch (error) { + // Unknowable: the row stays however Git lists it, as before this record existed. + console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) + return false } } @@ -306,45 +327,6 @@ function publishSafely(publish: () => void): void { } } -/** Taken before a listing reads Git; pass it to projectPendingWorktreeRemovals with the rows. */ -export function snapshotPendingWorktreeRemovals(): PendingWorktreeRemovals { - return pendingByWorktreeId.size === 0 ? NO_PENDING_REMOVALS : new Map(pendingByWorktreeId) -} - -/** - * Marks rows whose checkout this host is deleting, or leaves them out for a client that cannot - * read the marker: such a client already dropped the row on acceptance and would re-show it. - */ -export function projectPendingWorktreeRemovals< - T extends { hostId?: ExecutionHostId; removing?: true } ->( - rows: T[], - idOf: (row: T) => string, - clientReadsMarker: boolean, - pendingAtScan: PendingWorktreeRemovals -): T[] { - if (pendingByWorktreeId.size === 0 && pendingAtScan.size === 0) { - return rows - } - const projected: T[] = [] - for (const row of rows) { - const id = idOf(row) - const local = row.hostId === undefined || row.hostId === LOCAL_EXECUTION_HOST_ID - if (local && pendingByWorktreeId.has(id)) { - if (clientReadsMarker) { - projected.push({ ...row, removing: true }) - } - continue - } - const scanned = local ? pendingAtScan.get(id) : undefined - // Why: Git was read before this delete finished; unmarked, the gone row reads as a failed delete. - if (!scanned || !removedRecords.has(scanned)) { - projected.push(row) - } - } - return projected -} - export async function _settlePendingWorktreeRemovalsForTests(): Promise { while (jobsByWorktreeId.size > 0) { await Promise.all(jobsByWorktreeId.values()) @@ -352,7 +334,8 @@ export async function _settlePendingWorktreeRemovalsForTests(): Promise { } export function _resetPendingWorktreeRemovalsForTests(): void { - pendingByWorktreeId.clear() + pendingWorktreeRemovals.clear() + failedWorktreeRemovals.clear() jobsByWorktreeId.clear() settlementsByWorktreeId.clear() stopControllers.clear() @@ -360,5 +343,5 @@ export function _resetPendingWorktreeRemovalsForTests(): void { fence.release() } startupFencesByWorktreeId.clear() - recordsDirectory = null + setWorktreeRemovalRecordsDirectory(null) } diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts new file mode 100644 index 000000000000..306f3bbffda7 --- /dev/null +++ b/src/main/worktree-failed-removal.test.ts @@ -0,0 +1,253 @@ +// A delete that fails after Git dropped the checkout's registration: the leftover stays listed with +// the error until Delete retries it, the checkout disappears, or the user forgets it. Git is mocked +// here so this runs on every platform; the real-Git version is in +// runtime/runtime-failed-local-worktree-removal.test.ts. +import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { listWorktreesStrict } from './git/worktree' +import { beginTerminalInstall } from './ipc/watcher-removal-gate' +import { registerWorktreeChangeInvalidator } from './ipc/worktree-change-invalidators' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests, + loadWorktreeRemovalRecords, + resumeInterruptedWorktreeRemovals, + retryFailedWorktreeRemoval, + startBackgroundWorktreeRemoval, + waitForPendingWorktreeRemoval +} from './worktree-background-removal' +import { + projectPendingWorktreeRemovals, + snapshotPendingWorktreeRemovals, + withUnregisteredRemovalCheckouts +} from './worktree-removal-listing' +import { readWorktreeRemovalRecords } from './worktree-removal-records' +import { forgetFailedWorktreeRemoval } from './worktree-removal-table' + +vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) + +const GIT_ERROR = "error: failed to delete 'node_modules/a/LICENSE': Operation not permitted" +let directory = '' +let checkout = '' +let worktreeId = '' +const mainWorktree: GitWorktreeInfo = { + path: '/work/repo', + head: 'abc', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true +} + +beforeEach(async () => { + directory = await realpath(await mkdtemp(join(tmpdir(), 'orca-failed-removal-'))) + checkout = join(directory, 'feature') + worktreeId = `repo-1::${checkout}` + // What Git left: part of the checkout, `.git` already deleted. + await mkdir(join(checkout, 'node_modules', 'a'), { recursive: true }) + await writeFile(join(checkout, 'node_modules', 'a', 'LICENSE'), 'MIT\n') + await mkdir(join(directory, 'profile')) + await loadWorktreeRemovalRecords(join(directory, 'profile')) + vi.mocked(listWorktreesStrict).mockResolvedValue([mainWorktree]) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(async () => { + _resetPendingWorktreeRemovalsForTests() + vi.restoreAllMocks() + await rm(directory, { recursive: true, force: true }) +}) + +function startFailingRemoval(): Promise { + return startBackgroundWorktreeRemoval({ + removal: { + worktreeId, + repoId: 'repo-1', + repoPath: '/work/repo', + worktree: { path: checkout, branch: 'refs/heads/feature', head: 'abc' }, + deleteBranch: true, + force: true + }, + run: async () => { + throw new Error(GIT_ERROR) + }, + publish: () => {} + }) +} + +async function failRemoval(): Promise { + await expect(startFailingRemoval()).rejects.toThrow(GIT_ERROR) + await _settlePendingWorktreeRemovalsForTests() +} + +async function listRows(): Promise { + return withUnregisteredRemovalCheckouts('repo-1', [mainWorktree]) +} + +const leftoverRow = (): GitWorktreeInfo => ({ + path: checkout, + head: 'abc', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false, + removalError: GIT_ERROR +}) + +describe('a delete that fails after Git dropped the registration', () => { + it('keeps the leftover listed with the error, recorded on disk, and not pending', async () => { + await failRemoval() + + expect(await listRows()).toEqual([mainWorktree, leftoverRow()]) + const [record] = await readWorktreeRemovalRecords(join(directory, 'profile')) + expect(record).toMatchObject({ worktreeId, failure: { message: GIT_ERROR } }) + expect(waitForPendingWorktreeRemoval(worktreeId)).toBeUndefined() + // Not marked removing and not left out for older clients: it is a row they can delete again. + const rows = [{ id: worktreeId }] + expect( + projectPendingWorktreeRemovals( + rows, + (row) => row.id, + false, + snapshotPendingWorktreeRemovals() + ) + ).toEqual(rows) + // Nothing fences the leftover: a failed delete must not block terminals indefinitely. + beginTerminalInstall(checkout)() + }) + + it('invalidates cached listings, which still hold the registration Git dropped', async () => { + const invalidated = vi.fn() + const unregister = registerWorktreeChangeInvalidator(invalidated) + await failRemoval() + unregister() + + expect(invalidated).toHaveBeenCalledWith('repo-1') + }) + + it('clears the record as before when Git still registers the checkout', async () => { + vi.mocked(listWorktreesStrict).mockResolvedValue([ + mainWorktree, + { ...leftoverRow(), removalError: undefined } + ]) + await failRemoval() + + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + }) + + it('clears the record as before when the checkout is gone', async () => { + await rm(checkout, { recursive: true }) + await failRemoval() + + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + }) + + it('never retries it on its own, at startup or when interrupted removals resume', async () => { + await failRemoval() + _resetPendingWorktreeRemovalsForTests() + await loadWorktreeRemovalRecords(join(directory, 'profile')) + const jobFor = vi.fn() + + resumeInterruptedWorktreeRemovals(jobFor) + + expect(jobFor).not.toHaveBeenCalled() + expect(waitForPendingWorktreeRemoval(worktreeId)).toBeUndefined() + expect(await listRows()).toEqual([mainWorktree, leftoverRow()]) + beginTerminalInstall(checkout)() + }) + + it('runs the recorded removal again on Delete and clears the record once it succeeds', async () => { + await failRemoval() + const publish = vi.fn() + const run = vi.fn(async () => { + // The retry shows as removing while it runs. + expect(await listRows()).toEqual([ + mainWorktree, + { ...leftoverRow(), removalError: undefined } + ]) + await rm(checkout, { recursive: true }) + return {} + }) + + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => { + // The user's first choices, without the failure. + expect(record).toMatchObject({ deleteBranch: true, force: true }) + expect(record).not.toHaveProperty('failure') + return { run, publish } + }) + + // A second window's Delete joins the same run. + expect(waitForPendingWorktreeRemoval(worktreeId)).toBe(retried) + await expect(retried).resolves.toEqual({}) + await _settlePendingWorktreeRemovalsForTests() + expect(run).toHaveBeenCalledTimes(1) + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + expect(await listRows()).toEqual([mainWorktree]) + expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() + }) + + it('keeps the row with the new error when the retry fails the same way', async () => { + await failRemoval() + const retried = retryFailedWorktreeRemoval(worktreeId, undefined, () => ({ + run: async () => { + throw new Error('still not permitted') + }, + publish: () => {} + })) + + await expect(retried).rejects.toThrow('still not permitted') + await _settlePendingWorktreeRemovalsForTests() + expect(await listRows()).toEqual([ + mainWorktree, + { ...leftoverRow(), removalError: 'still not permitted' } + ]) + }) + + it('is not retried for another host', async () => { + await failRemoval() + + expect(retryFailedWorktreeRemoval(worktreeId, 'ssh:box', vi.fn())).toBeUndefined() + }) + + it('ends when the user forgets the workspace, leaving the files', async () => { + await failRemoval() + + forgetFailedWorktreeRemoval(worktreeId) + + await vi.waitFor(async () => + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + ) + expect(await listRows()).toEqual([mainWorktree]) + expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() + expect(await readdir(checkout)).toEqual(['node_modules']) + }) + + it('ends at the next listing once the checkout is deleted outside Orca', async () => { + await failRemoval() + await rm(checkout, { recursive: true }) + + expect(await listRows()).toEqual([mainWorktree]) + await vi.waitFor(async () => + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + ) + }) + + it('ends at startup once the checkout is deleted outside Orca', async () => { + await failRemoval() + _resetPendingWorktreeRemovalsForTests() + await rm(checkout, { recursive: true }) + + await loadWorktreeRemovalRecords(join(directory, 'profile')) + + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + }) + + it('ends at the next listing once a different checkout takes the path', async () => { + await failRemoval() + await mkdir(join(checkout, '.git')) + + expect(await listRows()).toEqual([mainWorktree]) + expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() + }) +}) diff --git a/src/main/worktree-removal-leftover.ts b/src/main/worktree-removal-leftover.ts new file mode 100644 index 000000000000..509524a5f42d --- /dev/null +++ b/src/main/worktree-removal-leftover.ts @@ -0,0 +1,22 @@ +import { lstat } from 'node:fs/promises' +import { join } from 'node:path' +import { getErrorCode } from './git/worktree-operation-options' +import { CLIENT_REMOVAL_HOME } from './worktree-removal-home-guard' +import { canSafelyRemoveOrphanedWorktreeDirectory } from './worktree-removal-safety' + +/** + * Whether a checkout path Git no longer registers still holds the removed checkout's own leftover: + * no `.git` (Git deleted it first), or a `.git` file naming the admin entry Git removed. Any other + * `.git` is a different checkout created at the path since. + */ +export async function isUnregisteredRemovalLeftover( + repoPath: string, + worktreePath: string +): Promise { + try { + await lstat(join(worktreePath, '.git')) + } catch (error) { + return getErrorCode(error) === 'ENOENT' + } + return canSafelyRemoveOrphanedWorktreeDirectory(worktreePath, repoPath, CLIENT_REMOVAL_HOME) +} diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts new file mode 100644 index 000000000000..fd4dd7bfce97 --- /dev/null +++ b/src/main/worktree-removal-listing.ts @@ -0,0 +1,100 @@ +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' +import { isUnregisteredRemovalLeftover } from './worktree-removal-leftover' +import type { WorktreeRemovalRecord } from './worktree-removal-records' +import { + failedWorktreeRemovals, + finishedWorktreeRemovals, + pendingWorktreeRemovals, + persistWorktreeRemovalRecords, + worktreeCheckoutExists +} from './worktree-removal-table' + +/** The removals pending when a listing began to read Git. */ +export type PendingWorktreeRemovals = ReadonlyMap + +const NO_PENDING_REMOVALS: PendingWorktreeRemovals = new Map() + +/** + * Git's rows for a local repo plus one for each removal this host still owns whose checkout Git no + * longer lists but is still on disk: a failed delete (carrying its error) or one still finishing. + * A failed delete ends here once its checkout is gone or a different checkout took the path. + */ +export async function withUnregisteredRemovalCheckouts( + repoId: string, + gitWorktrees: GitWorktreeInfo[] +): Promise { + const unlisted = [...pendingWorktreeRemovals.values(), ...failedWorktreeRemovals.values()].filter( + (record) => + record.repoId === repoId && + !gitWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath)) + ) + if (unlisted.length === 0) { + return gitWorktrees + } + const leftovers: GitWorktreeInfo[] = [] + let droppedFailure = false + for (const record of unlisted) { + const failed = failedWorktreeRemovals.get(record.worktreeId) === record + if ( + (await worktreeCheckoutExists(record.worktreePath)) && + (!failed || (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath))) + ) { + leftovers.push({ + path: record.worktreePath, + head: record.head, + branch: record.branch ? `refs/heads/${record.branch}` : '', + isBare: false, + isMainWorktree: false, + ...(record.failure ? { removalError: record.failure.message } : {}) + }) + } else if (failed) { + failedWorktreeRemovals.delete(record.worktreeId) + droppedFailure = true + } + } + if (droppedFailure) { + void persistWorktreeRemovalRecords() + } + return leftovers.length === 0 ? gitWorktrees : [...gitWorktrees, ...leftovers] +} + +/** Taken before a listing reads Git; pass it to projectPendingWorktreeRemovals with the rows. */ +export function snapshotPendingWorktreeRemovals(): PendingWorktreeRemovals { + return pendingWorktreeRemovals.size === 0 ? NO_PENDING_REMOVALS : new Map(pendingWorktreeRemovals) +} + +/** + * Marks rows whose checkout this host is deleting, or leaves them out for a client that cannot + * read the marker: such a client already dropped the row on acceptance and would re-show it. + */ +export function projectPendingWorktreeRemovals< + T extends { hostId?: ExecutionHostId; removing?: true } +>( + rows: T[], + idOf: (row: T) => string, + clientReadsMarker: boolean, + pendingAtScan: PendingWorktreeRemovals +): T[] { + if (pendingWorktreeRemovals.size === 0 && pendingAtScan.size === 0) { + return rows + } + const projected: T[] = [] + for (const row of rows) { + const id = idOf(row) + const local = row.hostId === undefined || row.hostId === LOCAL_EXECUTION_HOST_ID + if (local && pendingWorktreeRemovals.has(id)) { + if (clientReadsMarker) { + projected.push({ ...row, removing: true }) + } + continue + } + const scanned = local ? pendingAtScan.get(id) : undefined + // Why: Git was read before this delete finished; unmarked, the gone row reads as a failed delete. + if (!scanned || !finishedWorktreeRemovals.has(scanned)) { + projected.push(row) + } + } + return projected +} diff --git a/src/main/worktree-removal-records.ts b/src/main/worktree-removal-records.ts index 1fd3558d232a..b4e7dbdb7fa7 100644 --- a/src/main/worktree-removal-records.ts +++ b/src/main/worktree-removal-records.ts @@ -24,6 +24,13 @@ export type WorktreeRemovalRecord = { deleteBranch: boolean force: boolean requestedAt: number + /** The delete failed after Git dropped the registration with the checkout still on disk. */ + failure?: WorktreeRemovalFailure +} + +export type WorktreeRemovalFailure = { + message: string + failedAt: number } type PersistedWorktreeRemovalRecords = { @@ -35,8 +42,19 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function parseFailure(value: unknown): WorktreeRemovalFailure | null | undefined { + if (value === undefined) { + return undefined + } + return isRecord(value) && typeof value.message === 'string' && typeof value.failedAt === 'number' + ? { message: value.message, failedAt: value.failedAt } + : null +} + function parseRecord(value: unknown): WorktreeRemovalRecord | null { + const failure = isRecord(value) ? parseFailure(value.failure) : null if ( + failure === null || !isRecord(value) || typeof value.worktreeId !== 'string' || typeof value.repoId !== 'string' || @@ -59,7 +77,8 @@ function parseRecord(value: unknown): WorktreeRemovalRecord | null { head: value.head, deleteBranch: value.deleteBranch, force: value.force, - requestedAt: value.requestedAt + requestedAt: value.requestedAt, + ...(failure ? { failure } : {}) } } diff --git a/src/main/worktree-removal-table.ts b/src/main/worktree-removal-table.ts new file mode 100644 index 000000000000..79e3d56cc801 --- /dev/null +++ b/src/main/worktree-removal-table.ts @@ -0,0 +1,83 @@ +import { lstat } from 'node:fs/promises' +import { getErrorCode } from './git/worktree-operation-options' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' +import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktree-removal-records' + +// The accepted removals, mirrored to disk on every change; listings and joins read only this. +export const pendingWorktreeRemovals = new Map() +// Deletes that failed after Git dropped the registration: listed with their error until Delete +// retries them, the checkout disappears, or the user forgets the workspace. Never retried unasked. +export const failedWorktreeRemovals = new Map() +// Why weak: a listing that read Git before a delete finished holds the record until it replies. +export const finishedWorktreeRemovals = new WeakSet() +let recordsDirectory: string | null = null + +export function setWorktreeRemovalRecordsDirectory(directory: string | null): void { + recordsDirectory = directory +} + +export function persistWorktreeRemovalRecords(): Promise { + if (!recordsDirectory) { + return Promise.resolve() + } + return writeWorktreeRemovalRecords(recordsDirectory, () => [ + ...pendingWorktreeRemovals.values(), + ...failedWorktreeRemovals.values() + ]).catch((error: unknown) => { + // Why: bookkeeping must not gate the delete; a lost write only costs resuming it after a quit. + console.warn('[worktrees] failed to persist worktree removal records', error) + }) +} + +/** Unreadable counts as present: only a checkout proven gone ends a failed delete. */ +export async function worktreeCheckoutExists(worktreePath: string): Promise { + try { + await lstat(worktreePath) + return true + } catch (error) { + const code = getErrorCode(error) + return code !== 'ENOENT' && code !== 'ENOTDIR' + } +} + +/** The user dropped the workspace from Orca: its failed delete is no longer owed. Files stay. */ +export function forgetFailedWorktreeRemoval(worktreeId: string): void { + if (failedWorktreeRemovals.delete(worktreeId)) { + void persistWorktreeRemovalRecords() + } +} + +export function hasPendingWorktreeRemovals(): boolean { + return pendingWorktreeRemovals.size > 0 +} + +export function findPendingWorktreeRemovalConflict( + repoPath: string, + target: { worktreePath?: string; branch?: string } +): WorktreeRemovalRecord | undefined { + const branch = target.branch?.replace(/^refs\/heads\//, '') + for (const removal of pendingWorktreeRemovals.values()) { + if (!areWorktreePathsEqual(removal.repoPath, repoPath)) { + continue + } + if ( + (target.worktreePath && areWorktreePathsEqual(removal.worktreePath, target.worktreePath)) || + (branch && removal.branch === branch) + ) { + return removal + } + } + return undefined +} + +export function assertNoPendingWorktreeRemovalConflict( + repoPath: string, + target: { worktreePath?: string; branch?: string } +): void { + const removal = findPendingWorktreeRemovalConflict(repoPath, target) + if (removal) { + throw new Error( + `Orca is still deleting the workspace at ${removal.worktreePath}. Cleanup is pending; try again shortly.` + ) + } +} diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index 68e83c3268ca..56cd6c67c483 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -78,6 +78,8 @@ export type RuntimeWorktreePsSummary = { agents: RuntimeWorktreeAgentRow[] /** See `Worktree.removing`; sent only to clients that advertise background removal. */ removing?: true + /** See `GitWorktreeInfo.removalError`. */ + removalError?: string } export type RuntimeGitLocalBranches = { diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts index ce2bd025e28c..85419632cefa 100644 --- a/src/shared/worktree/types.ts +++ b/src/shared/worktree/types.ts @@ -37,6 +37,9 @@ export type GitWorktreeInfo = { /** True for the repo's main working tree (the first entry from `git worktree list`). * Linked worktrees created via `git worktree add` have this set to false. */ isMainWorktree: boolean + /** Not from Git: the error of a local delete that failed after Git dropped this checkout's + * registration. The host lists the leftover so Delete can retry it. */ + removalError?: string } /** Head/branch snapshot read from Git metadata files without spawning Git. From 13034a52739f2dc46e0b75eaf9a25290e8a43c25 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:00:02 -0700 Subject: [PATCH 29/46] fix(renderer): show a failed delete's host error on its card A row the host lists with removalError gets the existing delete-state error (no new element), cleared when the host stops listing it failed. A row this view marked Deleting that comes back failed, and a lost delete reply settled from the listing, report the host's error instead of the generic one. --- .../background-worktree-removal-bridge.ts | 91 +++++++++++++------ .../worktrees-background-removal.test.ts | 59 +++++++++++- .../teardown/host-worktree-removal-state.ts | 9 +- 3 files changed, 127 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts b/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts index ca219bdd03ea..17b70444ebf2 100644 --- a/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts @@ -13,34 +13,79 @@ type AppStoreApi = Pick // Delete states this bridge set from a host marker, keyed like deleteStateByWorktreeId. A state the // local delete flow set is left to that flow. const hostMarkedDeleteStates = new Map() +// Errors this bridge set from a row's `removalError`, cleared once the host stops listing it failed. +const hostFailedDeleteStates = new Map() function deleteStateKey(row: HostMarkedRow): string { return row.hostId ? getWorktreeHostIdentity(row) : row.id } +function showDeleteError(store: AppStoreApi, row: HostMarkedRow, error: string): void { + store.setState((s) => ({ + deleteStateByWorktreeId: { + ...s.deleteStateByWorktreeId, + [deleteStateKey(row)]: { + isDeleting: false, + ...(row.hostId ? { executionHostId: row.hostId } : {}), + error, + canForceDelete: false, + forceDeleteReason: null + } + } + })) +} + +function showHostFailure(store: AppStoreApi, row: Worktree & { removalError: string }): void { + hostFailedDeleteStates.set(deleteStateKey(row), { + id: row.id, + hostId: row.hostId, + error: row.removalError + }) + showDeleteError(store, row, row.removalError) +} + /** * Shows the existing Deleting card while the host lists a row as removing, for views that did not - * ask for the delete. The row leaving means it finished; the row returning unmarked means it did not. + * ask for the delete. The row leaving means it finished; the row returning unmarked means it did + * not, and a row the host lists with `removalError` shows that error until Delete retries it. */ export function reconcileHostWorktreeRemovals(store: AppStoreApi = useAppStore): void { settleHostWorktreeRemovals() - const state = store.getState() - const marked: HostMarkedRow[] = [] const listed = new Map() - for (const rows of Object.values(state.worktreesByRepo)) { + for (const rows of Object.values(store.getState().worktreesByRepo)) { for (const row of rows) { - const key = deleteStateKey(row) - listed.set(key, row) - if (!row.removing || hostMarkedDeleteStates.has(key)) { - continue - } - const current = getDeleteStateForWorktreeHost(row, state.deleteStateByWorktreeId) - if (current?.isDeleting && current.phase !== 'queued') { - continue + listed.set(deleteStateKey(row), row) + } + } + for (const [key, shown] of hostFailedDeleteStates) { + const row = listed.get(key) + if (row?.removalError === shown.error && !row.removing) { + continue + } + hostFailedDeleteStates.delete(key) + const current = store.getState().deleteStateByWorktreeId[key] + if (current && !current.isDeleting && current.error === shown.error) { + store.getState().clearWorktreeDeleteState(shown.id, shown.hostId) + } + } + const state = store.getState() + const marked: HostMarkedRow[] = [] + for (const [key, row] of listed) { + const current = getDeleteStateForWorktreeHost(row, state.deleteStateByWorktreeId) + if (row.removalError && !row.removing) { + if (!current && !hostMarkedDeleteStates.has(key)) { + showHostFailure(store, { ...row, removalError: row.removalError }) } - hostMarkedDeleteStates.set(key, { id: row.id, hostId: row.hostId }) - marked.push({ id: row.id, hostId: row.hostId }) + continue } + if (!row.removing || hostMarkedDeleteStates.has(key)) { + continue + } + if (current?.isDeleting && current.phase !== 'queued') { + continue + } + hostMarkedDeleteStates.set(key, { id: row.id, hostId: row.hostId }) + marked.push({ id: row.id, hostId: row.hostId }) } if (marked.length > 0) { state.markWorktreesDeleting(marked) @@ -56,20 +101,11 @@ export function reconcileHostWorktreeRemovals(store: AppStoreApi = useAppStore): } if (!listedRow) { store.getState().clearWorktreeDeleteState(row.id, row.hostId) - continue + } else if (listedRow.removalError) { + showHostFailure(store, { ...listedRow, removalError: listedRow.removalError }) + } else { + showDeleteError(store, row, UNFINISHED_WORKTREE_REMOVAL_ERROR) } - store.setState((s) => ({ - deleteStateByWorktreeId: { - ...s.deleteStateByWorktreeId, - [key]: { - isDeleting: false, - ...(row.hostId ? { executionHostId: row.hostId } : {}), - error: UNFINISHED_WORKTREE_REMOVAL_ERROR, - canForceDelete: false, - forceDeleteReason: null - } - } - })) } } @@ -93,4 +129,5 @@ export function registerBackgroundWorktreeRemovalBridge(unsubs: (() => void)[]): export function _resetBackgroundWorktreeRemovalBridgeForTests(): void { hostMarkedDeleteStates.clear() + hostFailedDeleteStates.clear() } diff --git a/src/renderer/src/store/slices/worktrees-background-removal.test.ts b/src/renderer/src/store/slices/worktrees-background-removal.test.ts index 2b8d1d8394e1..2a8fb846de4b 100644 --- a/src/renderer/src/store/slices/worktrees-background-removal.test.ts +++ b/src/renderer/src/store/slices/worktrees-background-removal.test.ts @@ -24,7 +24,7 @@ const hostKey = getWorktreeHostIdentity({ id: worktreeId, hostId: 'local' }) function seedRow( store: ReturnType, - overrides: { removing?: true } = {} + overrides: { removing?: true; removalError?: string } = {} ): void { seedStore(store, { worktreesByRepo: { @@ -204,6 +204,63 @@ describe('removing a worktree the host deletes in the background', () => { }) }) + it('shows the host error on a row the host lists as a failed delete, until it leaves', () => { + // A window that opened after the delete failed, or a restart after a failed startup finish. + seedRow(store, { removalError: 'Operation not permitted' }) + reconcileHostWorktreeRemovals(store) + expect(deleteState(store)).toMatchObject({ + isDeleting: false, + error: 'Operation not permitted', + canForceDelete: false + }) + + // Forgotten, or the checkout deleted outside Orca: the host stops listing it. + seedStore(store, { worktreesByRepo: { repo1: [] } }) + reconcileHostWorktreeRemovals(store) + expect(deleteState(store)).toBeUndefined() + }) + + it('shows Deleting while the host retries a failed delete, and its new error after', () => { + seedRow(store, { removalError: 'Operation not permitted' }) + reconcileHostWorktreeRemovals(store) + + seedRow(store, { removing: true }) + reconcileHostWorktreeRemovals(store) + expect(deleteState(store)).toMatchObject({ isDeleting: true, phase: 'deleting' }) + + seedRow(store, { removalError: 'Resource busy' }) + reconcileHostWorktreeRemovals(store) + expect(deleteState(store)).toMatchObject({ isDeleting: false, error: 'Resource busy' }) + }) + + it('shows the error the host lists when a delete it marked Deleting fails', () => { + seedRow(store, { removing: true }) + reconcileHostWorktreeRemovals(store) + + seedRow(store, { removalError: 'Operation not permitted' }) + reconcileHostWorktreeRemovals(store) + expect(deleteState(store)).toMatchObject({ + isDeleting: false, + error: 'Operation not permitted' + }) + }) + + it('reports the host error for a lost reply when the host lists the failed delete', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + seedRow(store) + store.setState({ + fetchWorktrees: vi.fn(async () => { + seedRow(store, { removalError: 'Operation not permitted' }) + return true + }) + }) + mockApi.worktrees.remove.mockRejectedValue(new Error('Request timed out: worktree.rm')) + + await expect( + store.getState().removeWorktree({ id: worktreeId, executionHostId: null }) + ).resolves.toEqual({ ok: false, error: 'Operation not permitted' }) + }) + it('leaves a delete this renderer started to that flow', () => { seedRow(store, { removing: true }) store.getState().markWorktreesDeleting([{ id: worktreeId, hostId: 'local' }]) diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts b/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts index e89fa82ecbb5..62567af4a538 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts @@ -13,7 +13,7 @@ import type { WorktreeSliceGet } from '../listing/worktree-slice-types' export const UNFINISHED_WORKTREE_REMOVAL_ERROR = 'The delete did not finish. Try again.' -type RemovalRow = Pick +type RemovalRow = Pick function rowHostId(row: Pick): ExecutionHostId { return row.hostId ?? LOCAL_EXECUTION_HOST_ID @@ -40,8 +40,9 @@ const pendingJudgements = new Set<() => void>() /** * Settles a delete whose reply was lost from the host's listing, as every other view does: the row - * leaving means the delete finished, and the row listed without `removing` means it did not. When - * the listing cannot be read either, rejects with the lost reply's error. + * leaving means the delete finished, and the row listed without `removing` means it did not (with + * the host's error when it lists one). When the listing cannot be read either, rejects with the + * lost reply's error. */ function waitForHostWorktreeRemoval(args: { hostId: ExecutionHostId | undefined @@ -62,7 +63,7 @@ function waitForHostWorktreeRemoval(args: { } pendingJudgements.delete(judge) if (row) { - reject(new Error(UNFINISHED_WORKTREE_REMOVAL_ERROR)) + reject(new Error(row.removalError ?? UNFINISHED_WORKTREE_REMOVAL_ERROR)) } else { resolve() } From 89f9d88cba682d1573b6122113d284ed34d7b532 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:08:31 -0700 Subject: [PATCH 30/46] test(worktrees): type the failed-removal listing and refresh mocks --- src/main/worktree-failed-removal.test.ts | 2 +- .../store/slices/worktrees-background-removal.test.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 306f3bbffda7..dfd90a06bd04 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -104,7 +104,7 @@ describe('a delete that fails after Git dropped the registration', () => { expect(record).toMatchObject({ worktreeId, failure: { message: GIT_ERROR } }) expect(waitForPendingWorktreeRemoval(worktreeId)).toBeUndefined() // Not marked removing and not left out for older clients: it is a row they can delete again. - const rows = [{ id: worktreeId }] + const rows: { id: string; hostId?: undefined }[] = [{ id: worktreeId }] expect( projectPendingWorktreeRemovals( rows, diff --git a/src/renderer/src/store/slices/worktrees-background-removal.test.ts b/src/renderer/src/store/slices/worktrees-background-removal.test.ts index 2a8fb846de4b..6bff5d80837d 100644 --- a/src/renderer/src/store/slices/worktrees-background-removal.test.ts +++ b/src/renderer/src/store/slices/worktrees-background-removal.test.ts @@ -248,12 +248,12 @@ describe('removing a worktree the host deletes in the background', () => { it('reports the host error for a lost reply when the host lists the failed delete', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) seedRow(store) - store.setState({ - fetchWorktrees: vi.fn(async () => { - seedRow(store, { removalError: 'Operation not permitted' }) - return true - }) + const refresh = vi.fn() + refresh.mockImplementation(async () => { + seedRow(store, { removalError: 'Operation not permitted' }) + return true }) + store.setState({ fetchWorktrees: refresh }) mockApi.worktrees.remove.mockRejectedValue(new Error('Request timed out: worktree.rm')) await expect( From d46623b886ac5afb219454bdb2d39f2f8413561f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:22:50 -0700 Subject: [PATCH 31/46] fix(worktrees): a failed delete's retry never removes a checkout Git registers at the path again The retry replays the recorded choices (force, branch deletion) that were made for the unregistered leftover. If the user removed the leftover and `git worktree add`ed the same branch at the path, the new checkout matched the record's branch and head, so Delete force-removed it with its uncommitted files, skipping the normal delete's cleanliness check. The retry now refuses a registered checkout and lets the record go, so the next Delete takes the normal path. --- ...time-failed-local-worktree-removal.test.ts | 21 ++++++++++++++++ src/main/worktree-background-removal.ts | 24 ++++++++++++++++--- src/main/worktree-failed-removal.test.ts | 19 +++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index f76ec75c95a0..16d3952f806c 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -279,4 +279,25 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) expect(await listedRows()).toEqual([]) }) + + it('never deletes a worktree Git registers at the path since, even on the same branch', async () => { + await failStartupFinish() + await setImmutable(false) + await rm(worktreePath, { recursive: true }) + await git(['worktree', 'add', '-q', worktreePath, 'feature']) + await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') + const purged: string[] = [] + + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => + interruptedLocalWorktreeRemovalJob(record, jobHost(purged)) + ) + await expect(retried).rejects.toThrow(/A different checkout is now at/) + await _settlePendingWorktreeRemovalsForTests() + + expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) + expect(await isRegistered(worktreePath)).toBe(true) + expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(purged).toEqual([]) + expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + }) }) diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 3488c8df3245..d450cab42390 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -172,7 +172,20 @@ export function retryFailedWorktreeRemoval( const { failure: _failure, ...record } = failed const settlement = addPendingRemoval(record) const job = jobFor(record) - runBackgroundWorktreeRemoval(record, job, persistWorktreeRemovalRecords()) + const leftoverOnly: BackgroundWorktreeRemovalJob = { + ...job, + run: async (stopSignal) => { + // Why: the recorded choices (force, branch) were for the leftover; a checkout Git registers + // at the path since is a new one, which only the normal delete's checks may remove. + if (await isCheckoutRegistered(record)) { + throw new Error( + `A different checkout is now at ${record.worktreePath}; Orca left it in place. Delete it again to remove it.` + ) + } + return job.run(stopSignal) + } + } + runBackgroundWorktreeRemoval(record, leftoverOnly, persistWorktreeRemovalRecords()) publishSafely(job.publish) return settlement.result } @@ -283,9 +296,8 @@ async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promis return false } try { - const registered = await listWorktreesStrict(record.repoPath) return ( - !registered.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath)) && + !(await isCheckoutRegistered(record)) && (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath)) ) } catch (error) { @@ -295,6 +307,12 @@ async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promis } } +async function isCheckoutRegistered(record: WorktreeRemovalRecord): Promise { + return (await listWorktreesStrict(record.repoPath)).some((worktree) => + areWorktreePathsEqual(worktree.path, record.worktreePath) + ) +} + // Why: the record should be on disk before Git deletes, so a quit resumes the delete, but a disk or // file pool stall must not hold the user's delete; past this, a crash only loses the resume. const RECORD_WRITE_WAIT_MS = 2_000 diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index dfd90a06bd04..36118f2ea39d 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -204,6 +204,25 @@ describe('a delete that fails after Git dropped the registration', () => { ]) }) + it('does not run the recorded removal once Git registers a checkout at the path again', async () => { + await failRemoval() + vi.mocked(listWorktreesStrict).mockResolvedValue([ + mainWorktree, + { ...leftoverRow(), removalError: undefined } + ]) + const run = vi.fn(async () => ({})) + + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', () => ({ + run, + publish: () => {} + })) + + await expect(retried).rejects.toThrow(/A different checkout is now at/) + await _settlePendingWorktreeRemovalsForTests() + expect(run).not.toHaveBeenCalled() + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + }) + it('is not retried for another host', async () => { await failRemoval() From 361b9290be3598c440011225afd2c03281d2fbd6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:05:11 -0700 Subject: [PATCH 32/46] fix(worktrees): a failed delete's record ends at startup once its repo is removed from Orca --- src/main/startup/main-process-ready-runtime.ts | 4 +++- src/main/worktree-background-removal.ts | 8 ++++++-- src/main/worktree-failed-removal.test.ts | 11 +++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index ac8fc8f0d32b..56b90970a954 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -45,7 +45,9 @@ export async function initializeReadyRuntimeServices(): Promise { throw new Error('Store must be initialized before ready services') } // Why before any listing: a delete a quit or crash interrupted must show as Deleting from first paint. - await loadWorktreeRemovalRecords(store.getProfileStorageDirectory()) + await loadWorktreeRemovalRecords(store.getProfileStorageDirectory(), (repoId) => + Boolean(store.getRepo(repoId)) + ) initializeMainProcessObservers() initializeMainProcessAccountServices() const runtime = initializeMainProcessRuntime() diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index d450cab42390..f1d348964a9a 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -42,12 +42,16 @@ const startupFencesByWorktreeId = new Map() * Loads removals a quit or crash interrupted, so listings mark them before the first paint and * session restore cannot open a terminal or watcher in a half-deleted checkout before the resume. */ -export async function loadWorktreeRemovalRecords(directory: string): Promise { +export async function loadWorktreeRemovalRecords( + directory: string, + hasRepo: (repoId: string) => boolean = () => true +): Promise { setWorktreeRemovalRecordsDirectory(directory) let droppedFailure = false for (const record of await readWorktreeRemovalRecords(directory)) { if (record.failure) { - if (await worktreeCheckoutExists(record.worktreePath)) { + // Why the repo: only its listing shows the row, so nothing else could end a removed repo's. + if (hasRepo(record.repoId) && (await worktreeCheckoutExists(record.worktreePath))) { failedWorktreeRemovals.set(record.worktreeId, record) } else { droppedFailure = true diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 36118f2ea39d..223540ebdc8c 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -262,6 +262,17 @@ describe('a delete that fails after Git dropped the registration', () => { expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) }) + it('ends at startup once its repo is removed from Orca, leaving the files', async () => { + await failRemoval() + _resetPendingWorktreeRemovalsForTests() + + await loadWorktreeRemovalRecords(join(directory, 'profile'), (repoId) => repoId !== 'repo-1') + + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() + expect(await readdir(checkout)).toEqual(['node_modules']) + }) + it('ends at the next listing once a different checkout takes the path', async () => { await failRemoval() await mkdir(join(checkout, '.git')) From b0f547296a8617c2b04b8715948528f3ddeaa79c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:12:20 -0700 Subject: [PATCH 33/46] fix(renderer): a failed delete's row offers Remove from Orca --- src/main/ipc/worktrees-failed-removal.test.ts | 4 +- .../ForgetFailedDeleteMenuItem.test.ts | 60 ++++++++++++++++ .../sidebar/ForgetFailedDeleteMenuItem.tsx | 61 +++++++++++++++++ ...rkspaceDialog.failed-local-delete.test.tsx | 60 ++++++++++++++++ .../sidebar/ForgetSshWorkspaceDialog.tsx | 68 ++++++++++++------- .../sidebar/WorktreeContextMenuView.tsx | 4 ++ src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + .../worktrees/teardown/remove-worktree.ts | 2 +- .../src/web/preload-api/web-worktrees-api.ts | 2 +- 14 files changed, 244 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts create mode 100644 src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx create mode 100644 src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.failed-local-delete.test.tsx diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index d4d1b51c6c66..16de31ad7e0e 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -206,9 +206,11 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { it('forget drops it from Orca without deleting anything', async () => { await failAfterGitDroppedIt() - await handlers['worktrees:forgetLocal'](null, { worktreeId: featureId }) + // As the sidebar's Remove from Orca sends it for a local row. + await handlers['worktrees:forgetLocal'](null, { worktreeId: featureId, hostId: 'local' }) expect(await listFeature()).toBeUndefined() + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(featureId, 'local') expect(finishUnregisteredWorktreeRemoval).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts b/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts new file mode 100644 index 000000000000..5ff8a1c1c43e --- /dev/null +++ b/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' +import { canForgetFailedLocalDelete } from './ForgetFailedDeleteMenuItem' + +vi.mock('@/store', () => ({ useAppStore: vi.fn() })) + +const failedRow = { + id: 'repo-1::/workspace/feature-wt', + displayName: 'feature-wt', + removalError: "error: failed to delete '/workspace/feature-wt': Operation not permitted" +} +const localRepo = { connectionId: null } + +describe('canForgetFailedLocalDelete', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('offers Remove from Orca on a local row whose delete failed', () => { + expect(canForgetFailedLocalDelete(failedRow, localRepo)).toBe(true) + expect( + canForgetFailedLocalDelete({ ...failedRow, hostId: LOCAL_EXECUTION_HOST_ID }, localRepo) + ).toBe(true) + }) + + it('is not offered on a row without a failed delete', () => { + expect(canForgetFailedLocalDelete({ ...failedRow, removalError: undefined }, localRepo)).toBe( + false + ) + }) + + it('is not offered on rows another process owns', () => { + expect( + canForgetFailedLocalDelete({ ...failedRow, hostId: toSshExecutionHostId('box') }, localRepo) + ).toBe(false) + expect(canForgetFailedLocalDelete(failedRow, { connectionId: 'box' })).toBe(false) + expect( + canForgetFailedLocalDelete( + { + ...failedRow, + hostId: toRuntimeExecutionHostId('env-1'), + runtimeOwnerEnvironmentId: 'env-1' + }, + localRepo + ) + ).toBe(false) + expect( + canForgetFailedLocalDelete({ ...failedRow, runtimeOwnerEnvironmentId: 'env-1' }, localRepo) + ).toBe(false) + }) + + it('is not offered in a paired web client, which cannot forget', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + expect(canForgetFailedLocalDelete(failedRow, localRepo)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx b/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx new file mode 100644 index 000000000000..a31a66eb01c2 --- /dev/null +++ b/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx @@ -0,0 +1,61 @@ +import { CircleX } from 'lucide-react' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' + +type FailedDeleteRow = Pick< + Worktree, + 'id' | 'displayName' | 'hostId' | 'removalError' | 'runtimeOwnerEnvironmentId' +> + +/** + * A local row whose delete failed partway: Delete retries it, this removes it from Orca only. Only + * rows this window's own main process lists qualify, since that process holds the failed delete. + */ +export function canForgetFailedLocalDelete( + worktree: FailedDeleteRow, + repo: Pick | null | undefined +): boolean { + return ( + Boolean(worktree.removalError) && + (!worktree.hostId || parseExecutionHostId(worktree.hostId)?.kind === 'local') && + !worktree.runtimeOwnerEnvironmentId && + !repo?.connectionId && + !isPairedWebClientWindow() + ) +} + +export function ForgetFailedDeleteMenuItem({ + worktree, + repo, + disabled +}: { + worktree: FailedDeleteRow + repo: Pick | null | undefined + disabled: boolean +}): React.JSX.Element | null { + const openModal = useAppStore((s) => s.openModal) + if (!canForgetFailedLocalDelete(worktree, repo)) { + return null + } + return ( + + openModal('forget-ssh-workspace', { + worktreeId: worktree.id, + displayName: worktree.displayName, + resolution: { kind: 'not-ssh' }, + ...(worktree.hostId ? { executionHostId: worktree.hostId } : {}) + }) + } + > + + {translate('auto.components.sidebar.ForgetSshWorkspaceDialog.forget', 'Remove from Orca')} + + ) +} diff --git a/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.failed-local-delete.test.tsx b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.failed-local-delete.test.tsx new file mode 100644 index 000000000000..7a52e5f79ce7 --- /dev/null +++ b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.failed-local-delete.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const state = { + modalData: { + worktreeId: 'repo-1::/workspace/feature-wt', + displayName: 'feature-wt', + resolution: { kind: 'not-ssh' }, + executionHostId: 'local' + }, + closeModal: vi.fn(), + removeWorktree: vi.fn(async () => ({ ok: true })), + sshTargetLabels: new Map(), + removedSshTargetLabels: new Map() + } + return { state } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), + { getState: () => mocks.state } + ) +})) +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, vars?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => vars?.[name] ?? '') +})) +vi.mock('./delete-worktree-flow', () => ({ runWorktreeDeleteWithToast: vi.fn() })) + +import { ForgetSshWorkspaceDialog } from './ForgetSshWorkspaceDialog' + +describe('ForgetSshWorkspaceDialog for a local row whose delete failed', () => { + afterEach(() => { + cleanup() + }) + + it('offers only Remove from Orca, which forgets the row on its own host', async () => { + render() + + expect(screen.getByText('Remove “feature-wt” from Orca?')).toBeTruthy() + expect( + screen.getByText( + 'Removes this workspace from Orca only. Its remaining files and its branch stay on disk.' + ) + ).toBeTruthy() + expect(screen.queryByText('Reconnect & Delete')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Remove from Orca' })) + + await waitFor(() => expect(mocks.state.closeModal).toHaveBeenCalled()) + expect(mocks.state.removeWorktree).toHaveBeenCalledWith( + { id: 'repo-1::/workspace/feature-wt', executionHostId: 'local' }, + false, + { mode: 'forget-local' } + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx index d9a602f19198..d9f25aa0b3ae 100644 --- a/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx +++ b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx @@ -14,7 +14,7 @@ import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { runWorktreeDeleteWithToast } from './delete-worktree-flow' -import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import type { SshWorkspaceForgetResolution } from './ssh-workspace-forget-resolution' @@ -22,6 +22,8 @@ type ForgetSshWorkspaceModalData = { worktreeId: string displayName: string resolution: SshWorkspaceForgetResolution + /** Only with `not-ssh`: a local row whose delete failed partway (ForgetFailedDeleteMenuItem). */ + executionHostId?: ExecutionHostId } function isForgetModalData(data: unknown): data is ForgetSshWorkspaceModalData { @@ -53,12 +55,13 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null { } const { worktreeId, displayName, resolution } = modalData const canReconnect = resolution.kind === 'disconnected' - // This dialog only opens for a workspace pinned to a named SSH target, so that - // target IS the host the removal was confirmed against (STA-4343). + const failedLocalDelete = resolution.kind === 'not-ssh' + // An SSH workspace's named target IS the host the removal was confirmed against (STA-4343). const removalTarget: WorktreeRemovalTarget = { id: worktreeId, - executionHostId: - resolution.kind === 'not-ssh' ? null : toSshExecutionHostId(resolution.targetId) + executionHostId: failedLocalDelete + ? (modalData.executionHostId ?? null) + : toSshExecutionHostId(resolution.targetId) } const done = (): void => { @@ -98,7 +101,7 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null { } } - // Remove Orca's records only — never touches remote files, worktrees, or branches. + // Remove Orca's records only — never touches files, worktrees, or branches. const handleForget = async (): Promise => { setBusy('forget') try { @@ -132,33 +135,46 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null { - - {translate( - 'auto.components.sidebar.ForgetSshWorkspaceDialog.title', - 'Delete “{{name}}”?', - { - name: displayName - } - )} - - - {canReconnect + {failedLocalDelete ? null : } + {failedLocalDelete ? translate( - 'auto.components.sidebar.ForgetSshWorkspaceDialog.disconnectedBody', - 'The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.' + 'auto.components.sidebar.ForgetSshWorkspaceDialog.removeTitle', + 'Remove “{{name}}” from Orca?', + { name: displayName } ) : translate( - 'auto.components.sidebar.ForgetSshWorkspaceDialog.ghostBody', - '{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.', - { host: hostLabel } + 'auto.components.sidebar.ForgetSshWorkspaceDialog.title', + 'Delete “{{name}}”?', + { + name: displayName + } )} + + + {failedLocalDelete + ? translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.failedDeleteBody', + 'Removes this workspace from Orca only. Its remaining files and its branch stay on disk.' + ) + : canReconnect + ? translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.disconnectedBody', + 'The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.' + ) + : translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.ghostBody', + '{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.', + { host: hostLabel } + )} -
- - {hostLabel} -
+ {failedLocalDelete ? null : ( +
+ + {hostLabel} +
+ )} {/* Why: the ghost-host description already states files are untouched, so only repeat the reassurance on the reconnect (disconnected) path. */} diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx index 4e4605f70f69..7410011de3ca 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx @@ -36,6 +36,7 @@ import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import type { WorktreeContextMenuModel } from './use-worktree-context-menu-model' import { WorktreeStatusMenuItems } from './WorktreeStatusMenuItems' import { WorktreeContextMenuOverlays } from './WorktreeContextMenuOverlays' +import { ForgetFailedDeleteMenuItem } from './ForgetFailedDeleteMenuItem' import { CLOSE_ALL_CONTEXT_MENUS_EVENT, WORKTREE_CONTEXT_MENU_SCOPE_ATTR, @@ -313,6 +314,9 @@ export default function WorktreeContextMenuView({ model }: { model: WorktreeCont onSleep={handleCloseTerminals} onSleepSubtree={handleSleepSubtree} /> + {!isMultiContext ? ( + + ) : null} {/* Why: primary checkout rows can't be git-worktree-removed, so keep a disabled Delete Worktree for parity with non-primary cards and pair it with the enabled Remove Project action below. */} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 91f2275b0e2e..15242eabef50 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6307,6 +6307,8 @@ "reconnectFailed": "Reconnection failed", "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", "title": "Delete “{{name}}”?", + "removeTitle": "Remove “{{name}}” from Orca?", + "failedDeleteBody": "Removes this workspace from Orca only. Its remaining files and its branch stay on disk.", "disconnectedBody": "The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.", "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", "cancel": "Cancel", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3aff95b8d5e0..c5a0b6ef2298 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5341,6 +5341,8 @@ "reconnectFailed": "Error de reconexión", "forgetBody": "Elimina este espacio de trabajo solo de Orca. Los archivos, el worktree de Git y las ramas en {{host}} permanecen intactos.", "title": "¿Eliminar “{{name}}”?", + "removeTitle": "¿Eliminar “{{name}}” de Orca?", + "failedDeleteBody": "Elimina este espacio de trabajo solo de Orca. Los archivos restantes y su rama se conservan en el disco.", "disconnectedBody": "El host SSH de este espacio de trabajo no está conectado. Reconéctalo para eliminarlo también en el remoto, o elimínalo solo de Orca.", "ghostBody": "{{host}} ya no es un host SSH guardado, por lo que este espacio de trabajo ya no está conectado a un host activo. Solo se puede eliminar de Orca — los archivos y ramas en el remoto permanecen intactos.", "cancel": "Cancelar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 42058065579b..ca841cbdeca8 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -6248,6 +6248,8 @@ "reconnectFailed": "Échec de la reconnexion", "forgetBody": "Retire uniquement cet espace de travail d'Orca. Les fichiers, le worktree Git et les branches sur {{host}} restent intacts.", "title": "Supprimer « {{name}} » ?", + "removeTitle": "Retirer « {{name}} » d'Orca ?", + "failedDeleteBody": "Retire uniquement cet espace de travail d'Orca. Les fichiers restants et sa branche sont conservés sur le disque.", "disconnectedBody": "L'hôte SSH de cet espace de travail n'est pas connecté. Reconnectez-le pour le supprimer aussi à distance, ou retirez-le seulement d'Orca.", "ghostBody": "{{host}} n'est plus un hôte SSH enregistré ; cet espace de travail n'est donc plus connecté à un hôte actif. Il ne peut être que retiré d'Orca — les fichiers et branches distants restent intacts.", "cancel": "Annuler", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cc86474fe8b8..0498549e19a4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6171,6 +6171,8 @@ "reconnectFailed": "再接続に失敗しました", "forgetBody": "このワークスペースを Orca からのみ削除します。ファイル、Git ワークツリー、{{host}}のブランチはそのまま残ります。", "title": "「{{name}}」を削除しますか?", + "removeTitle": "「{{name}}」を Orca から削除しますか?", + "failedDeleteBody": "このワークスペースを Orca からのみ削除します。残りのファイルとブランチはディスク上にそのまま残ります。", "disconnectedBody": "このワークスペースの SSH ホストが接続されていません。再接続してリモートでも削除するか、Orca からのみ削除してください。", "ghostBody": "{{host}} は保存済みの SSH ホストではなくなったため、このワークスペースは稼働中のホストに接続されていません。Orca から削除することのみ可能です。リモート上のファイルとブランチはそのまま残ります。", "cancel": "キャンセル", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index aa8bf3912d13..27482a38c4b5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6171,6 +6171,8 @@ "reconnectFailed": "재연결 실패", "forgetBody": "이 작업 공간을 Orca에서만 제거합니다. 파일, Git 워크트리, {{host}}의 브랜치는 그대로 유지됩니다.", "title": "Delete “{{name}}”?", + "removeTitle": "“{{name}}”을(를) Orca에서 제거할까요?", + "failedDeleteBody": "이 작업 공간을 Orca에서만 제거합니다. 남은 파일과 브랜치는 디스크에 그대로 유지됩니다.", "disconnectedBody": "이 워크트리의 SSH 호스트가 연결되어 있지 않습니다. 재연결하여 원격에서도 삭제하거나 Orca에서만 제거하세요.", "ghostBody": "{{host}}은(는) 더 이상 저장된 SSH 호스트가 아니므로 이 워크스페이스는 라이브 호스트에 연결되어 있지 않습니다. Orca에서 제거만 할 수 있으며, 원격의 파일과 브랜치는 그대로 유지됩니다.", "cancel": "취소", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e61395b42918..bee466ee0fe4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6171,6 +6171,8 @@ "reconnectFailed": "重新连接失败", "forgetBody": "仅从 Orca 中移除此工作区。文件、Git 工作树和 {{host}} 上的分支保持不变。", "title": "从 Orca 中移除“{{name}}”?", + "removeTitle": "从 Orca 中移除“{{name}}”?", + "failedDeleteBody": "仅从 Orca 中移除此工作区。剩余的文件及其分支保留在磁盘上。", "disconnectedBody": "此工作区的 SSH 主机未连接。重新连接以同时在远程删除,或仅从 Orca 中移除。", "ghostBody": "{{host}} 不再是已保存的 SSH 主机,因此此工作区不再连接到活跃主机。只能从 Orca 中移除 — 远程文件和分支保持不变。", "cancel": "取消", diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 3ca041aeb753..bd36be49a778 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -114,7 +114,7 @@ export function createRemoveWorktree( if (!forgetLocalOnly) { removalGenerationGuard?.assertCurrent() } - // Why: forget-local clears Orca's records via local IPC regardless of host — the remote is gone or unreachable. + // Why: forget-local clears Orca's records via local IPC regardless of host, reachable or not. const target = getActiveRuntimeTarget( removalRoute ? settingsForWorktreeOperationRoute(get().settings, removalRoute) diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts index ee626b187547..348478443fad 100644 --- a/src/renderer/src/web/preload-api/web-worktrees-api.ts +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -145,7 +145,7 @@ export function createWorktreesApi(): NonNullable['worktrees worktreeRemovalReplyTimeoutMs(skipArchive !== true) ) }, - // Why: forget-locally clears a desktop workspace pinned to a dead SSH host; a paired web client has no such ghost state. + // Why: forget-locally clears records only the desktop's own main process holds; a paired web client has none. forgetLocal: () => { throw new Error('Forgetting a workspace is unavailable in paired web clients.') }, From 46bfbbf4ca1a4d4dc603d958a5f92d6c2abc062d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:13:57 -0700 Subject: [PATCH 34/46] test(worktrees): type the failed-removal IPC test's module mocks without casts --- src/main/ipc/worktrees-failed-removal.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index 16de31ad7e0e..4e4b16f5c6a2 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -59,27 +59,27 @@ vi.mock('../ssh/ssh-target-registry', async () => vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record + await importOriginal>() ) ) vi.mock('../worktree-runner-script', async (importOriginal) => (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record + await importOriginal>() ) ) vi.mock('../effective-hook-config', async (importOriginal) => (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record + await importOriginal>() ) ) vi.mock('../setup-hook-env-vars', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record + await importOriginal>() ) ) vi.mock('./worktree-logic', async (importOriginal) => (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record + await importOriginal>() ) ) vi.mock('../terminal-history-deletion', async () => From 0ed46bf207e3391b8c5086a7bc83828cd593182b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:38:31 -0700 Subject: [PATCH 35/46] fix(worktrees): Delete picks retry or a normal delete from Git's current listing --- src/main/ipc/worktrees-failed-removal.test.ts | 20 ++++++++ .../removal/execute-worktree-removal.ts | 10 ++++ .../register-worktree-removal-handlers.ts | 8 +--- .../orca-runtime-remove-managed-worktree.ts | 5 +- ...runtime-resolve-worktree-removal-target.ts | 47 ++++++++++++------- .../worktree-removal-failed-retry.spec.ts | 33 ++++++++++++- src/main/worktree-background-removal.ts | 9 ++-- src/main/worktree-removal-table.ts | 28 ++++++++++- 8 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index 4e4b16f5c6a2..221d5c7cdade 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -203,6 +203,26 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { expect(store.removeWorktreeMeta).not.toHaveBeenCalled() }) + it('Delete takes the normal delete once Git registers a checkout at the path again', async () => { + await failAfterGitDroppedIt() + // A new checkout at the same path: the recorded choices were for the leftover, not for it. + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue({}) + + await remove({ worktreeId: featureId, force: false }) + await _settlePendingWorktreeRemovalsForTests() + + expect(finishUnregisteredWorktreeRemoval).not.toHaveBeenCalled() + expect(removeWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-wt', + false, + expect.anything() + ) + listWorktreesMock.mockResolvedValue([mockKnownFeatureWorktree()[0]]) + expect(await listFeature()).toBeUndefined() + }) + it('forget drops it from Orca without deleting anything', async () => { await failAfterGitDroppedIt() diff --git a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts index fc4053c579a4..04e25eac3667 100644 --- a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts +++ b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts @@ -36,6 +36,8 @@ import { removeFolderWorkspace } from './remove-folder-workspace' import { removeUnregisteredWorktree } from './remove-unregistered-worktree' import { removeRegisteredRemoteWorktree } from './remove-registered-remote-worktree' import { removeRegisteredLocalWorktree } from './remove-registered-local-worktree' +import { retryFailedLocalWorktreeRemoval } from './retry-failed-local-worktree-removal' +import { retryFailedRemovalUnlessRegistered } from '../../../worktree-removal-table' /** * Refuses a repo row whose two host spellings disagree. @@ -114,6 +116,14 @@ export async function executeWorktreeRemoval( registeredWorktrees, resolveWorktreeRemovalHomeForHost(removalHostId) ) + if ( + !repo.connectionId && + retryFailedRemovalUnlessRegistered(args.worktreeId, worktreePath, registeredWorktrees, () => + retryFailedLocalWorktreeRemoval(context, args, removalHostId) + ) + ) { + return { removing: true } + } if (!registeredWorktree) { return removeUnregisteredWorktree( context, diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index 969167458036..d91743b01850 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -17,7 +17,6 @@ import { waitForPendingWorktreeRemoval } from '../../../worktree-background-removal' import { runSerializedWorktreeRemovalAcceptance } from '../../../worktree-removal-acceptance-queue' -import { retryFailedLocalWorktreeRemoval } from './retry-failed-local-worktree-removal' export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { const { store, options, worktreeRemovalsInFlight } = context @@ -32,11 +31,8 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo } // The resolved repo supplies host ownership when legacy callers omit args.hostId. const removalHostId = getRepoExecutionHostId(repo) - // Why: a retry or a second window asking while Git still deletes joins that removal, and - // Delete on the leftover of one that failed after Git dropped it runs that removal again. - const pending = - waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) ?? - retryFailedLocalWorktreeRemoval(context, args, removalHostId) + // Why: a retry or a second window asking while Git still deletes joins that removal. + const pending = waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) if (pending) { return { ...(await pending), catalogVersion: getLocalWorktreeCatalogVersion(repoId) } } diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index ea0eaa2be198..ec9611a1d048 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -51,7 +51,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM const cleanupHostId = parseExecutionHostId(hostId)?.id const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId) // Why: a retry or a second client asking while Git still deletes joins that removal. - const pending = this.joinOrRetryWorktreeRemoval(removalTarget.id, options) + const pending = this.joinPendingWorktreeRemoval(removalTarget.id, options) if (pending) { return options.waitForBackgroundRemoval ? await pending : { removing: true } } @@ -131,6 +131,9 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM registeredWorktrees, removalHome ) + if (this.retryFailedLocalRemoval(route, removalTarget, registeredWorktrees, options)) { + return { removing: true } + } if (!registeredWorktree) { return removeRuntimeUnregisteredWorktree({ repo, diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 9dc345f48063..9f4870fa912e 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -34,6 +34,8 @@ import { waitForPendingWorktreeRemoval } from '../worktree-background-removal' import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' +import { retryFailedRemovalUnlessRegistered } from '../worktree-removal-table' +import type { GitWorktreeInfo } from '../../shared/worktree/types' export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { protected async resolveWorktreeRemovalTarget( @@ -61,28 +63,39 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith ) } - /** - * The removal a request for this worktree waits on: the one still running, or the leftover of one - * that failed after Git dropped the registration, run again. - */ - protected joinOrRetryWorktreeRemoval( + /** The removal a request for this worktree waits on: the one still running. */ + protected joinPendingWorktreeRemoval( worktreeId: string, options: RemoveManagedWorktreeOptions ): Promise | undefined { - const hostId = parseExecutionHostId(options.hostId)?.id + return waitForPendingWorktreeRemoval(worktreeId, parseExecutionHostId(options.hostId)?.id) + } + + /** + * Delete on the leftover of a local delete that failed after Git dropped the registration, while + * Git's listing still does not register the path: runs that removal again. True when it did. + */ + protected retryFailedLocalRemoval( + route: { kind: string }, + target: { id: string; path: string }, + registeredWorktrees: readonly GitWorktreeInfo[], + options: RemoveManagedWorktreeOptions + ): boolean { const store = this.store - const pending = waitForPendingWorktreeRemoval(worktreeId, hostId) - if (pending || !store) { - return pending + if (route.kind !== 'local' || !store) { + return false } - return retryFailedWorktreeRemoval(worktreeId, hostId, (record) => - interruptedLocalWorktreeRemovalJob(record, { - ...this.localRemovalJobHost(store), - stopPtys: () => - this.stopPtysForDestructiveWorktreeRemoval(record.worktreeId, { - allowUnverifiedStop: options.allowUnverifiedPtyStop === true - }) - }) + const hostId = parseExecutionHostId(options.hostId)?.id + return retryFailedRemovalUnlessRegistered(target.id, target.path, registeredWorktrees, () => + retryFailedWorktreeRemoval(target.id, hostId, (record) => + interruptedLocalWorktreeRemovalJob(record, { + ...this.localRemovalJobHost(store), + stopPtys: () => + this.stopPtysForDestructiveWorktreeRemoval(record.worktreeId, { + allowUnverifiedStop: options.allowUnverifiedPtyStop === true + }) + }) + ) ) } diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 9d9e32121910..526b40cef17f 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -4,7 +4,15 @@ import { existsSync } from 'node:fs' import { realpath } from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { join, mkdir, mkdtemp, removeWorktree, rm, tmpdir } from '../orca-runtime-test-mocks.spec' +import { + join, + listWorktreesStrict, + mkdir, + mkdtemp, + removeWorktree, + rm, + tmpdir +} from '../orca-runtime-test-mocks.spec' import { TEST_REPO_ID, TEST_REPO_PATH } from '../orca-runtime-test-fixtures.spec' import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' import { @@ -78,6 +86,29 @@ describe('runtime Delete on a failed delete’s leftover', () => { expect(await readWorktreeRemovalRecords(directory)).toEqual([]) }) + it('takes the normal delete once Git registers a checkout at the path again', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + // A new checkout at the same path: the recorded choices were for the leftover, not for it. + vi.mocked(listWorktreesStrict).mockResolvedValue([ + { + path: leftover, + head: 'def', + branch: 'refs/heads/other', + isBare: false, + isMainWorktree: false + } + ]) + vi.mocked(removeWorktree).mockResolvedValue({}) + const runtime = createWorktreeRemovalRuntime() + + await runtime.removeManagedWorktree(`id:${leftoverId}`, { waitForBackgroundRemoval: true }) + await _settlePendingWorktreeRemovalsForTests() + + expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, leftover, false, expect.anything()) + expect(existsSync(join(leftover, 'node_modules'))).toBe(true) + expect(await readWorktreeRemovalRecords(directory)).toEqual([]) + }) + it('replies to a waiting client once the retry finishes', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const runtime = createWorktreeRemovalRuntime() diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index f1d348964a9a..6df7be6726c0 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -158,8 +158,9 @@ export function startBackgroundWorktreeRemoval( } /** - * Delete on a row whose earlier delete failed after Git dropped its registration: runs the recorded - * removal again, with the choices the user made the first time. Undefined when there is none. + * Delete on a row whose earlier delete failed after Git dropped its registration, once Git's current + * listing still does not register the path: runs the recorded removal again, with the choices the + * user made the first time. Undefined when there is none. */ export function retryFailedWorktreeRemoval( worktreeId: string, @@ -179,8 +180,8 @@ export function retryFailedWorktreeRemoval( const leftoverOnly: BackgroundWorktreeRemovalJob = { ...job, run: async (stopSignal) => { - // Why: the recorded choices (force, branch) were for the leftover; a checkout Git registers - // at the path since is a new one, which only the normal delete's checks may remove. + // Why: the recorded choices (force, branch) were for the leftover; a checkout Git registered + // at the path after the caller listed is a new one, which only the normal delete may remove. if (await isCheckoutRegistered(record)) { throw new Error( `A different checkout is now at ${record.worktreePath}; Orca left it in place. Delete it again to remove it.` diff --git a/src/main/worktree-removal-table.ts b/src/main/worktree-removal-table.ts index 79e3d56cc801..14ae2566a0d5 100644 --- a/src/main/worktree-removal-table.ts +++ b/src/main/worktree-removal-table.ts @@ -2,11 +2,14 @@ import { lstat } from 'node:fs/promises' import { getErrorCode } from './git/worktree-operation-options' import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktree-removal-records' +import type { RemoveWorktreeResult } from '../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' // The accepted removals, mirrored to disk on every change; listings and joins read only this. export const pendingWorktreeRemovals = new Map() // Deletes that failed after Git dropped the registration: listed with their error until Delete -// retries them, the checkout disappears, or the user forgets the workspace. Never retried unasked. +// retries them, the checkout disappears or is replaced, or the user forgets the workspace. Never +// retried unasked. export const failedWorktreeRemovals = new Map() // Why weak: a listing that read Git before a delete finished holds the record until it replies. export const finishedWorktreeRemovals = new WeakSet() @@ -40,13 +43,34 @@ export async function worktreeCheckoutExists(worktreePath: string): Promise[], + retry: () => Promise | undefined +): boolean { + if (registeredWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, worktreePath))) { + forgetFailedWorktreeRemoval(worktreeId) + return false + } + return retry() !== undefined +} + export function hasPendingWorktreeRemovals(): boolean { return pendingWorktreeRemovals.size > 0 } From a3b5f9db49dcfac895cd64caebed36130f6c97a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:50:14 -0700 Subject: [PATCH 36/46] fix(worktrees): a failed delete's retry checks the leftover again right before deleting it --- src/main/git/worktree-removal.ts | 8 ++- src/main/ipc/worktrees-failed-removal.test.ts | 5 +- ...time-failed-local-worktree-removal.test.ts | 63 +++++++++++++++++++ ...time-interrupted-local-worktree-removal.ts | 11 ++-- src/main/worktree-background-removal.ts | 23 +++---- src/main/worktree-removal-leftover.ts | 44 ++++++++++++- 6 files changed, 132 insertions(+), 22 deletions(-) diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 39d2056f708e..8924fe04a9f1 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -128,15 +128,21 @@ function deleteBranchOfRemovedWorktree( /** * Finishes a removal whose checkout Git no longer registers (it finished deleting, or an earlier * run did): leftover files, stale admin records, then the branch. Already-gone parts are done. + * `assertLeftover` refuses unless the path still holds the removed checkout's own leftover. */ export async function finishUnregisteredWorktreeRemoval( repoPath: string, worktreePath: string, branch: { name: string; head: string } | null, + assertLeftover: () => Promise, options: RemoveWorktreeOptions = {} ): Promise { try { - await runUnderWorktreeDeleteLimit(() => removeCheckoutLeftByGit(worktreePath, options)) + await runUnderWorktreeDeleteLimit(async () => { + // Why in the slot: the wait can outlast two large deletes, and the path may change meanwhile. + await assertLeftover() + await removeCheckoutLeftByGit(worktreePath, options) + }) await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options)).catch( (error: unknown) => console.warn(`[git] worktree prune failed in ${repoPath}`, error) ) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index 221d5c7cdade..fb645c79162c 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -14,6 +14,7 @@ import type { Worktree } from '../../shared/worktree/types' import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' import type * as WorktreeRemovalModule from '../git/worktree-removal' import type * as WorktreeRemovalTable from '../worktree-removal-table' +import type * as WorktreeRemovalLeftover from '../worktree-removal-leftover' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, @@ -111,7 +112,8 @@ vi.mock('../worktree-removal-table', async (importOriginal) => ({ ...(await importOriginal()), worktreeCheckoutExists: vi.fn(async () => true) })) -vi.mock('../worktree-removal-leftover', () => ({ +vi.mock('../worktree-removal-leftover', async (importOriginal) => ({ + ...(await importOriginal()), isUnregisteredRemovalLeftover: vi.fn(async () => true) })) @@ -182,6 +184,7 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { '/workspace/repo', '/workspace/feature-wt', { name: 'feature', head: 'feature' }, + expect.any(Function), {} ) expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith( diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 16d3952f806c..dad9aaca91e9 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -16,6 +16,10 @@ import type * as HostTreeRemoval from '../host-tree-removal' import { removeHostTree } from '../host-tree-removal' import { listWorktreesStrict, removeWorktree } from '../git/worktree' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' +import { + _worktreeDeleteLimitSnapshotForTests, + runUnderWorktreeDeleteLimit +} from '../git/worktree-delete-limit' import { acquireWatcherRemovalGate, beginTerminalInstall } from '../ipc/watcher-removal-gate' import { _resetPendingWorktreeRemovalsForTests, @@ -300,4 +304,63 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(purged).toEqual([]) expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) }) + + it('never deletes a worktree Git registers inside the leftover', async () => { + await failInSession() + await setImmutable(false) + const nested = join(worktreePath, 'sub') + await git(['worktree', 'add', '-q', nested, '-b', 'nested']) + await writeFile(join(nested, 'unsaved.txt'), 'work\n') + const purged: string[] = [] + + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => + interruptedLocalWorktreeRemovalJob(record, jobHost(purged)) + ) + await expect(retried).rejects.toThrow(/contains another registered worktree/) + await _settlePendingWorktreeRemovalsForTests() + + expect(existsSync(join(nested, 'unsaved.txt'))).toBe(true) + expect(await isRegistered(nested)).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + // The row keeps the refusal, so the user can move the nested worktree or forget this one. + expect(await listedRows()).toContainEqual({ + path: worktreePath, + removalError: expect.stringMatching(/contains another registered worktree/) + }) + }) + + it('checks the leftover again inside the delete slot, right before deleting', async () => { + await failInSession() + await setImmutable(false) + // Both delete slots busy, as behind two large deletes. + let releaseSlots = (): void => {} + const held = new Promise((resolve) => { + releaseSlots = resolve + }) + const holders = [ + runUnderWorktreeDeleteLimit(() => held), + runUnderWorktreeDeleteLimit(() => held) + ] + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => + interruptedLocalWorktreeRemovalJob(record, jobHost([])) + ) + const settled = retried!.then( + () => undefined, + (reason: unknown) => reason + ) + await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1)) + // While it waits, the leftover is replaced by a different checkout. + await rm(worktreePath, { recursive: true }) + await mkdir(worktreePath) + await git(['init', '-q'], worktreePath) + await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') + releaseSlots() + await Promise.all(holders) + + expect(String(await settled)).toMatch(/A different checkout is now at/) + await _settlePendingWorktreeRemovalsForTests() + expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index e093190055f7..414220895d7e 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -18,7 +18,11 @@ import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' -import { isUnregisteredRemovalLeftover } from '../worktree-removal-leftover' +import { + assertUnregisteredRemovalLeftover, + differentCheckoutAtPathError, + isUnregisteredRemovalLeftover +} from '../worktree-removal-leftover' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { WorktreeRemovalRecord } from '../worktree-removal-records' import { @@ -150,9 +154,7 @@ async function finishInterruptedLocalWorktreeRemoval( ? !isRecordedCheckout(deletable, record) : !(await isUnregisteredRemovalLeftover(repo.path, record.worktreePath)) ) { - throw new Error( - `A different checkout is now at ${record.worktreePath}; Orca left it in place. Delete it again to remove it.` - ) + throw differentCheckoutAtPathError(record.worktreePath) } // Why: Git deletes `.git` wherever it falls in directory order (early on NTFS) and refuses to // remove a checkout left without it; restoring the link from Git's admin entry lets Git finish. @@ -182,6 +184,7 @@ async function finishInterruptedLocalWorktreeRemoval( repo.path, record.worktreePath, record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null, + () => assertUnregisteredRemovalLeftover(repo.path, record.worktreePath, localOptions), localOptions ) removed = true diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 6df7be6726c0..0dc578925a7d 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -1,14 +1,16 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' import type { RemoveWorktreeResult } from '../shared/worktree/create-types' import type { GitWorktreeInfo } from '../shared/worktree/types' -import { listWorktreesStrict } from './git/worktree' import { normalizeLocalBranchRef } from './git/worktree-operation-options' -import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { acquireWatcherRemovalGate, type WatcherRemovalGate } from './ipc/watcher-removal-gate' import { runWorktreeChangeInvalidators } from './ipc/worktree-change-invalidators' import { parseWslPath } from './wsl' import { readWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktree-removal-records' -import { isUnregisteredRemovalLeftover } from './worktree-removal-leftover' +import { + differentCheckoutAtPathError, + isCheckoutRegistered, + isUnregisteredRemovalLeftover +} from './worktree-removal-leftover' import { failedWorktreeRemovals, finishedWorktreeRemovals, @@ -158,9 +160,8 @@ export function startBackgroundWorktreeRemoval( } /** - * Delete on a row whose earlier delete failed after Git dropped its registration, once Git's current - * listing still does not register the path: runs the recorded removal again, with the choices the - * user made the first time. Undefined when there is none. + * Delete on a failed delete's leftover that Git's current listing still does not register: runs the + * recorded removal again, with the choices the user made the first time. Undefined when none. */ export function retryFailedWorktreeRemoval( worktreeId: string, @@ -183,9 +184,7 @@ export function retryFailedWorktreeRemoval( // Why: the recorded choices (force, branch) were for the leftover; a checkout Git registered // at the path after the caller listed is a new one, which only the normal delete may remove. if (await isCheckoutRegistered(record)) { - throw new Error( - `A different checkout is now at ${record.worktreePath}; Orca left it in place. Delete it again to remove it.` - ) + throw differentCheckoutAtPathError(record.worktreePath) } return job.run(stopSignal) } @@ -312,12 +311,6 @@ async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promis } } -async function isCheckoutRegistered(record: WorktreeRemovalRecord): Promise { - return (await listWorktreesStrict(record.repoPath)).some((worktree) => - areWorktreePathsEqual(worktree.path, record.worktreePath) - ) -} - // Why: the record should be on disk before Git deletes, so a quit resumes the delete, but a disk or // file pool stall must not hold the user's delete; past this, a crash only loses the resume. const RECORD_WRITE_WAIT_MS = 2_000 diff --git a/src/main/worktree-removal-leftover.ts b/src/main/worktree-removal-leftover.ts index 509524a5f42d..3e17e415ee30 100644 --- a/src/main/worktree-removal-leftover.ts +++ b/src/main/worktree-removal-leftover.ts @@ -1,8 +1,14 @@ import { lstat } from 'node:fs/promises' import { join } from 'node:path' +import { listWorktreesStrict } from './git/worktree' import { getErrorCode } from './git/worktree-operation-options' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { CLIENT_REMOVAL_HOME } from './worktree-removal-home-guard' -import { canSafelyRemoveOrphanedWorktreeDirectory } from './worktree-removal-safety' +import { + assertWorktreeDoesNotContainRegisteredWorktree, + canSafelyRemoveOrphanedWorktreeDirectory +} from './worktree-removal-safety' +import type { GitWorktreeExecOptions } from './git/worktree-operation-options' /** * Whether a checkout path Git no longer registers still holds the removed checkout's own leftover: @@ -20,3 +26,39 @@ export async function isUnregisteredRemovalLeftover( } return canSafelyRemoveOrphanedWorktreeDirectory(worktreePath, repoPath, CLIENT_REMOVAL_HOME) } + +/** The refusal when the path no longer holds the removed checkout's own leftover. */ +export function differentCheckoutAtPathError(worktreePath: string): Error { + return new Error( + `A different checkout is now at ${worktreePath}; Orca left it in place. Delete it again to remove it.` + ) +} + +/** Whether Git registers a checkout at the recorded path now. */ +export async function isCheckoutRegistered(record: { + repoPath: string + worktreePath: string +}): Promise { + return (await listWorktreesStrict(record.repoPath)).some((worktree) => + areWorktreePathsEqual(worktree.path, record.worktreePath) + ) +} + +/** + * Refuses unless the path still holds the removed checkout's own leftover, with no worktree Git + * registers at or inside it. Run right before the delete: the path can change while it waits. + */ +export async function assertUnregisteredRemovalLeftover( + repoPath: string, + worktreePath: string, + options: GitWorktreeExecOptions = {} +): Promise { + const worktrees = await listWorktreesStrict(repoPath, options) + if (worktrees.some((worktree) => areWorktreePathsEqual(worktree.path, worktreePath))) { + throw differentCheckoutAtPathError(worktreePath) + } + assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, worktrees) + if (!(await isUnregisteredRemovalLeftover(repoPath, worktreePath))) { + throw differentCheckoutAtPathError(worktreePath) + } +} From 4e91d3f245ddaea4b3089d742b31956bcd3631d5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:53:49 -0700 Subject: [PATCH 37/46] fix(worktrees): Remove from Orca reaches paired clients and matches the failed row's own host --- src/main/ipc/worktrees-failed-removal.test.ts | 7 +++++- .../register-worktree-forget-handlers.ts | 2 ++ .../startup/main-process-ready-runtime.ts | 6 ++--- .../startup/worktree-removal-records-load.ts | 20 ++++++++++++++++ src/main/worktree-failed-removal.test.ts | 23 ++++++++++++++++++- .../ForgetFailedDeleteMenuItem.test.ts | 9 ++++++++ .../sidebar/ForgetFailedDeleteMenuItem.tsx | 6 +++-- 7 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 src/main/startup/worktree-removal-records-load.ts diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index fb645c79162c..bf24f7a430ab 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -8,6 +8,7 @@ import { removeWorktreeMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { mockKnownFeatureWorktree } from './worktrees-test-fixtures' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { Worktree } from '../../shared/worktree/types' @@ -154,9 +155,11 @@ async function failAfterGitDroppedIt(): Promise { } describe('a failed delete Git no longer registers, over desktop IPC', () => { + let runtime: WorktreeRuntimeStub + beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}) - setupWorktreeHandlers() + runtime = setupWorktreeHandlers() }) afterEach(() => { @@ -234,6 +237,8 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { expect(await listFeature()).toBeUndefined() expect(store.removeWorktreeMeta).toHaveBeenCalledWith(featureId, 'local') + // Paired clients list the failed row too, so they must hear it is gone. + expect(runtime.notifyWorktreesChangedForRemoteClients).toHaveBeenCalledWith('repo-1') expect(finishUnregisteredWorktreeRemoval).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts index 90416804fb17..1d3ad9787005 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts @@ -142,6 +142,8 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi } } notifyWorktreesChanged(mainWindow, repoId) + // Why: paired clients list this host's failed rows too, and refetch only on this event. + runtime.notifyWorktreesChangedForRemoteClients(repoId) return {} })() worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget }) diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 56b90970a954..afe59f573c93 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -32,7 +32,7 @@ import { import { initializeMainProcessAutomations } from './main-process-automations' import { initializeMainProcessPlugins } from './main-process-plugins' import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from '../worktree-trash' -import { loadWorktreeRemovalRecords } from '../worktree-background-removal' +import { loadWorktreeRemovalRecordsForStore } from './worktree-removal-records-load' import { runAfterFirstWindowShown } from './first-window-deferral' import { logStartupMilestone } from './startup-diagnostics' @@ -45,9 +45,7 @@ export async function initializeReadyRuntimeServices(): Promise { throw new Error('Store must be initialized before ready services') } // Why before any listing: a delete a quit or crash interrupted must show as Deleting from first paint. - await loadWorktreeRemovalRecords(store.getProfileStorageDirectory(), (repoId) => - Boolean(store.getRepo(repoId)) - ) + await loadWorktreeRemovalRecordsForStore(store) initializeMainProcessObservers() initializeMainProcessAccountServices() const runtime = initializeMainProcessRuntime() diff --git a/src/main/startup/worktree-removal-records-load.ts b/src/main/startup/worktree-removal-records-load.ts new file mode 100644 index 000000000000..5d988baaf6c5 --- /dev/null +++ b/src/main/startup/worktree-removal-records-load.ts @@ -0,0 +1,20 @@ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' +import { loadWorktreeRemovalRecords } from '../worktree-background-removal' + +/** + * Loads this host's removal records at startup. A failed delete is kept only while its repo's LOCAL + * copy is in Orca: only local listings show the row, and an SSH copy under the same id never would. + */ +export function loadWorktreeRemovalRecordsForStore(store: { + getProfileStorageDirectory: () => string + getRepos: () => readonly Pick[] +}): Promise { + return loadWorktreeRemovalRecords(store.getProfileStorageDirectory(), (repoId) => + store + .getRepos() + .some( + (repo) => repo.id === repoId && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID + ) + ) +} diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 223540ebdc8c..b60d18e306a2 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -26,6 +26,7 @@ import { } from './worktree-removal-listing' import { readWorktreeRemovalRecords } from './worktree-removal-records' import { forgetFailedWorktreeRemoval } from './worktree-removal-table' +import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-records-load' vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) @@ -266,13 +267,33 @@ describe('a delete that fails after Git dropped the registration', () => { await failRemoval() _resetPendingWorktreeRemovalsForTests() - await loadWorktreeRemovalRecords(join(directory, 'profile'), (repoId) => repoId !== 'repo-1') + // Only an SSH copy of the project is left under the same repo id. + await loadWorktreeRemovalRecordsForStore({ + getProfileStorageDirectory: () => join(directory, 'profile'), + getRepos: () => [{ id: 'repo-1', connectionId: 'box', executionHostId: null }] + }) expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() expect(await readdir(checkout)).toEqual(['node_modules']) }) + it('is kept at startup while the repo’s local copy is still in Orca', async () => { + await failRemoval() + _resetPendingWorktreeRemovalsForTests() + + await loadWorktreeRemovalRecordsForStore({ + getProfileStorageDirectory: () => join(directory, 'profile'), + getRepos: () => [ + { id: 'repo-1', connectionId: 'box', executionHostId: null }, + { id: 'repo-1', connectionId: null, executionHostId: null } + ] + }) + + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) + expect(await listRows()).toHaveLength(2) + }) + it('ends at the next listing once a different checkout takes the path', async () => { await failRemoval() await mkdir(join(checkout, '.git')) diff --git a/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts b/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts index 5ff8a1c1c43e..bfaf9c481956 100644 --- a/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts +++ b/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts @@ -27,6 +27,15 @@ describe('canForgetFailedLocalDelete', () => { ).toBe(true) }) + it('trusts a local row’s own host over a same-id SSH copy of its repo', () => { + expect( + canForgetFailedLocalDelete( + { ...failedRow, hostId: LOCAL_EXECUTION_HOST_ID }, + { connectionId: 'box' } + ) + ).toBe(true) + }) + it('is not offered on a row without a failed delete', () => { expect(canForgetFailedLocalDelete({ ...failedRow, removalError: undefined }, localRepo)).toBe( false diff --git a/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx b/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx index a31a66eb01c2..75fb7a8e7b19 100644 --- a/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx +++ b/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx @@ -22,9 +22,11 @@ export function canForgetFailedLocalDelete( ): boolean { return ( Boolean(worktree.removalError) && - (!worktree.hostId || parseExecutionHostId(worktree.hostId)?.kind === 'local') && + // Why the repo only without a hostId: a repo looked up by id may be another host's copy. + (worktree.hostId + ? parseExecutionHostId(worktree.hostId)?.kind === 'local' + : !repo?.connectionId) && !worktree.runtimeOwnerEnvironmentId && - !repo?.connectionId && !isPairedWebClientWindow() ) } From a4391d012489c917c937b7241061153c855680e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:55:54 -0700 Subject: [PATCH 38/46] fix(worktrees): a failed delete's retry re-lists only its own repo's authorized roots --- .../removal/retry-failed-local-worktree-removal.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts b/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts index a801d012062d..30fd0351728f 100644 --- a/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts +++ b/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts @@ -2,7 +2,7 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../share import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' import { retryFailedWorktreeRemoval } from '../../../worktree-background-removal' import { interruptedLocalWorktreeRemovalJob } from '../../../runtime/runtime-interrupted-local-worktree-removal' -import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { invalidateAuthorizedRootsCacheForRepo } from '../../registered-worktree-roots-scoped-invalidation' import type { RemoveWorktreeArgs } from '../ipc-context-schemas' import type { WorktreeIpcContext } from '../worktree-ipc-context' import { @@ -44,7 +44,7 @@ export function retryFailedLocalWorktreeRemoval( pushTarget ) }, - purge: ({ worktreeId }) => { + purge: ({ worktreeId, repoId }) => { runtime.clearOptimisticReconcileToken(worktreeId) removeWorktreeMetadataAndTransientState( store, @@ -52,7 +52,7 @@ export function retryFailedLocalWorktreeRemoval( LOCAL_EXECUTION_HOST_ID, args.snapshotPruneBatchId ) - invalidateAuthorizedRootsCache() + invalidateAuthorizedRootsCacheForRepo(store, repoId) }, onRemoved: ({ worktreeId, worktreePath }) => options?.onWorktreeLifecycle?.({ kind: 'removed', worktreeId, path: worktreePath }), From 633c4e5d0667ac7f87824f35e67bae3c762fd6a1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:39:45 -0700 Subject: [PATCH 39/46] fix(worktrees): a second Delete joins a retry already running, and the startup finish keeps its last-resort delete --- src/main/ipc/worktrees-failed-removal.test.ts | 24 ++++++++++++++ .../worktree-removal-failed-retry.spec.ts | 24 +++++++++++++- ...time-failed-local-worktree-removal.test.ts | 31 +++++++++++++++++-- ...time-interrupted-local-worktree-removal.ts | 5 ++- src/main/worktree-background-removal.ts | 5 +-- src/main/worktree-removal-table.ts | 2 +- 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index bf24f7a430ab..201cca49eba6 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -19,6 +19,7 @@ import type * as WorktreeRemovalLeftover from '../worktree-removal-leftover' import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, + retryFailedWorktreeRemoval, startBackgroundWorktreeRemoval } from '../worktree-background-removal' @@ -209,6 +210,29 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { expect(store.removeWorktreeMeta).not.toHaveBeenCalled() }) + it('joins a retry another client started while this Delete listed Git', async () => { + await failAfterGitDroppedIt() + const [main] = mockKnownFeatureWorktree() + listWorktreesMock.mockResolvedValue([main]) + const otherClientsRetry = vi.fn(async () => ({})) + listWorktreesMock.mockImplementationOnce(async () => { + // Another client's Delete takes the failed record during this Delete's `git worktree list`. + void retryFailedWorktreeRemoval(featureId, 'local', () => ({ + run: otherClientsRetry, + publish: () => {} + })) + return [main] + }) + + await expect(remove({ worktreeId: featureId })).resolves.not.toHaveProperty('removing') + await _settlePendingWorktreeRemovalsForTests() + + expect(otherClientsRetry).toHaveBeenCalledTimes(1) + // Neither a second retry nor the delete for leftovers without a record ran. + expect(finishUnregisteredWorktreeRemoval).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + it('Delete takes the normal delete once Git registers a checkout at the path again', async () => { await failAfterGitDroppedIt() // A new checkout at the same path: the recorded choices were for the leftover, not for it. diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 526b40cef17f..c17c3ced2235 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -18,7 +18,8 @@ import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-buil import { _resetPendingWorktreeRemovalsForTests, _settlePendingWorktreeRemovalsForTests, - loadWorktreeRemovalRecords + loadWorktreeRemovalRecords, + retryFailedWorktreeRemoval } from '../../worktree-background-removal' import { readWorktreeRemovalRecords, @@ -109,6 +110,27 @@ describe('runtime Delete on a failed delete’s leftover', () => { expect(await readWorktreeRemovalRecords(directory)).toEqual([]) }) + it('joins a retry another client started while this Delete listed Git', async () => { + const otherClientsRetry = vi.fn(async () => ({})) + vi.mocked(listWorktreesStrict).mockImplementationOnce(async () => { + void retryFailedWorktreeRemoval(leftoverId, 'local', () => ({ + run: otherClientsRetry, + publish: () => {} + })) + return [] + }) + const runtime = createWorktreeRemovalRuntime() + + await expect( + runtime.removeManagedWorktree(`id:${leftoverId}`, { waitForBackgroundRemoval: true }) + ).resolves.toEqual({}) + + expect(otherClientsRetry).toHaveBeenCalledTimes(1) + // Only the joined retry ran: the leftover is still there because its stub deleted nothing. + expect(existsSync(join(leftover, 'node_modules'))).toBe(true) + expect(removeWorktree).not.toHaveBeenCalled() + }) + it('replies to a waiting client once the retry finishes', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const runtime = createWorktreeRemovalRuntime() diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index dad9aaca91e9..6ef584d58027 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -13,6 +13,8 @@ import type { Repo } from '../../shared/repo-types' import { removeTree } from '../../shared/windows-transient-lock-removal' import type { Store } from '../persistence' import type * as HostTreeRemoval from '../host-tree-removal' +import type * as GitFileRestore from '../git/worktree-git-file-restore' +import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' import { removeHostTree } from '../host-tree-removal' import { listWorktreesStrict, removeWorktree } from '../git/worktree' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' @@ -42,6 +44,10 @@ import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local- vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: () => ({}) })) +vi.mock('../git/worktree-git-file-restore', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, restoreMissingWorktreeGitFile: vi.fn(actual.restoreMissingWorktreeGitFile) } +}) vi.mock('../host-tree-removal', async (importOriginal) => { const actual = await importOriginal() return { ...actual, removeHostTree: vi.fn(actual.removeHostTree) } @@ -100,7 +106,12 @@ function jobHost(purged: string[], stopPtys = vi.fn(async () => {})) { } /** A delete a quit interrupted, finished at the next start, where Git fails on the locked file. */ -async function failStartupFinish(): Promise { +function failStartupFinish(): Promise { + return finishAtStartup([]) +} + +/** Resumes a recorded delete of the checkout as the next start does; resolves with its error. */ +async function finishAtStartup(purged: string[]): Promise { const record: WorktreeRemovalRecord = { worktreeId, repoId: repo.id, @@ -116,7 +127,7 @@ async function failStartupFinish(): Promise { await loadWorktreeRemovalRecords(recordsDir) const joined = waitForPendingWorktreeRemoval(worktreeId)! resumeInterruptedWorktreeRemovals((interrupted) => - interruptedLocalWorktreeRemovalJob(interrupted, jobHost([])) + interruptedLocalWorktreeRemovalJob(interrupted, jobHost(purged)) ) const error = await joined.then( () => undefined, @@ -363,4 +374,20 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) expect(removeHostTree).not.toHaveBeenCalled() }) + + it('at startup, still deletes the recorded checkout when its missing .git cannot be restored', async () => { + await setImmutable(false) + // Git deleted `.git` first and the link cannot be written back, so Git cannot remove it. + await rm(join(worktreePath, '.git')) + vi.mocked(restoreMissingWorktreeGitFile).mockResolvedValueOnce(false) + const purged: string[] = [] + + expect(await finishAtStartup(purged)).toBeUndefined() + + expect(removeHostTree).toHaveBeenCalledWith(worktreePath) + expect(existsSync(worktreePath)).toBe(false) + expect(await isRegistered(worktreePath)).toBe(false) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([worktreeId]) + }) }) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 414220895d7e..9f94feac0dc0 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -184,7 +184,10 @@ async function finishInterruptedLocalWorktreeRemoval( repo.path, record.worktreePath, record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null, - () => assertUnregisteredRemovalLeftover(repo.path, record.worktreePath, localOptions), + // Why only unregistered: a registered checkout here was just proven to be the recorded one. + deletable + ? async () => {} + : () => assertUnregisteredRemovalLeftover(repo.path, record.worktreePath, localOptions), localOptions ) removed = true diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 0dc578925a7d..700ecd045cae 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -161,7 +161,8 @@ export function startBackgroundWorktreeRemoval( /** * Delete on a failed delete's leftover that Git's current listing still does not register: runs the - * recorded removal again, with the choices the user made the first time. Undefined when none. + * recorded removal again, with the choices the user made the first time, or joins the one another + * request started while this one listed Git. Undefined when neither. */ export function retryFailedWorktreeRemoval( worktreeId: string, @@ -173,7 +174,7 @@ export function retryFailedWorktreeRemoval( ? failedWorktreeRemovals.get(worktreeId) : undefined if (!failed) { - return undefined + return waitForPendingWorktreeRemoval(worktreeId, hostId) } const { failure: _failure, ...record } = failed const settlement = addPendingRemoval(record) diff --git a/src/main/worktree-removal-table.ts b/src/main/worktree-removal-table.ts index 14ae2566a0d5..e312949bf174 100644 --- a/src/main/worktree-removal-table.ts +++ b/src/main/worktree-removal-table.ts @@ -56,7 +56,7 @@ export function forgetFailedWorktreeRemoval(worktreeId: string): void { /** * Delete's choice for a workspace whose earlier delete failed, from Git's listing taken now: a * checkout Git registers at the path again is a new one, so the failed record is dropped and the - * normal delete runs; while Git does not, `retry` runs the recorded removal. True when it did. + * normal delete runs; while Git does not, `retry` runs or joins the recorded removal. True then. */ export function retryFailedRemovalUnlessRegistered( worktreeId: string, From 0ae473cc56b9653919a2ac4f52651bd99790a042 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:49:38 -0700 Subject: [PATCH 40/46] fix(renderer): a failed delete's card says it failed, and Delete keeps the error for its dialog --- .../sidebar/DeleteWorktreeDialog.test.tsx | 53 ++++++ .../sidebar/DeleteWorktreeDialog.tsx | 13 +- .../WorktreeCard.delete-failed.test.tsx | 158 ++++++++++++++++++ .../sidebar/delete-worktree-flow.test.ts | 35 +++- .../sidebar/delete-worktree-flow.ts | 13 +- .../sidebar/worktree-card-secondary-rows.tsx | 20 ++- .../sidebar/worktree-delete-state-reset.ts | 21 +++ src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + 13 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-delete-state-reset.ts diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 25b103138f2b..0cb1c475ed71 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -159,6 +159,13 @@ function buttonText(props: Record): string { return renderToStaticMarkup(<>{props.children as ReactNode}) } +function clickCancel(): void { + const onClick = mocks.buttonProps.find((props) => props.variant === 'outline')?.onClick + if (typeof onClick === 'function') { + onClick() + } +} + function visibleMarkupText(markup: string): string { return markup.replace(/<[^>]*>/g, '') } @@ -381,6 +388,52 @@ describe('DeleteWorktreeDialog lineage copy', () => { expect(mocks.state.removeWorktree).not.toHaveBeenCalled() }) + it('shows a failed delete’s host error and keeps it when the dialog is cancelled', async () => { + const failure = 'Operation not permitted' + const workspace = { + ...makeWorktree('Failed workspace', '/workspaces/failed'), + removalError: failure + } + mocks.state.modalData = { worktreeId: workspace.id } + mocks.state.allWorktrees.mockReturnValue([workspace]) + mocks.state.deleteStateByWorktreeId = { + [workspace.id]: { + isDeleting: false, + error: failure, + canForceDelete: false, + forceDeleteReason: null + } + } + + const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog') + const markup = renderToStaticMarkup() + clickCancel() + + expect(markup).toContain(failure) + expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalled() + expect(mocks.state.closeModal).toHaveBeenCalled() + }) + + it('still clears an ordinary delete error when the dialog is cancelled', async () => { + const workspace = makeWorktree('Dirty workspace', '/workspaces/dirty') + mocks.state.modalData = { worktreeId: workspace.id } + mocks.state.allWorktrees.mockReturnValue([workspace]) + mocks.state.deleteStateByWorktreeId = { + [workspace.id]: { + isDeleting: false, + error: 'Worktree has uncommitted changes', + canForceDelete: true, + forceDeleteReason: 'dirty' + } + } + + const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog') + renderToStaticMarkup() + clickCancel() + + expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith(workspace.id) + }) + it('notifies the dialog caller after a toast force delete succeeds', async () => { const workspace = makeWorktree('Workspace', '/workspaces/workspace') const onDeleted = vi.fn() diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index f6656118f162..c74c5b631d04 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -9,6 +9,8 @@ import { import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { runWorktreeDeletesInParallel } from './delete-worktree-flow' +import { resetWorktreeDeleteState } from './worktree-delete-state-reset' +import type { WorktreeDeleteIdentity } from './worktree-delete-request' import { composeWorktreeHostIdentity, getWorktreeHostIdentity @@ -226,18 +228,23 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const currentState = currentTarget ? getDeleteStateForWorktreeHost(currentTarget, state) : undefined + const rowOf = (target: Pick) => + worktrees.find( + (item) => item.id === target.id && (!target.hostId || item.hostId === target.hostId) + ) if (isBatchDelete) { for (const target of deleteStateTargets) { if (!getDeleteStateForWorktreeHost(target, state)?.isDeleting) { - clearWorktreeDeleteState(target.id, target.hostId) + resetWorktreeDeleteState(target, rowOf(target)) } } } else if (worktreeId && !currentState?.isDeleting) { - clearWorktreeDeleteState(worktreeId, currentTarget?.hostId) + const target = { id: worktreeId, hostId: currentTarget?.hostId } + resetWorktreeDeleteState(target, rowOf(target)) } closeModal() }, - [clearWorktreeDeleteState, closeModal, deleteStateTargets, isBatchDelete, worktreeId] + [closeModal, deleteStateTargets, isBatchDelete, worktreeId, worktrees] ) const persistDontAskAgainPreference = useCallback((): void => { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx new file mode 100644 index 000000000000..93eb0afe5b27 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx @@ -0,0 +1,158 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactNode } from 'react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type WorktreeCardComponent from './WorktreeCard' + +const fetchHostedReviewForBranch = vi.fn() +const fetchIssue = vi.fn() +const fetchLinearIssue = vi.fn() +const openModal = vi.fn() +const updateWorktreeMeta = vi.fn() + +let WorktreeCard: typeof WorktreeCardComponent +let sshConnectionStates = new Map() +let sshTargetLabels = new Map() +let removedSshTargetLabels = new Map() +let runtimeStatusByEnvironmentId = new Map() +let runtimeEnvironments: { id: string; name: string }[] = [] +let sshStateByEnvironment = new Map() +let worktreesByRepo: Record = {} +let worktreeCardProperties: WorktreeCardProperty[] = ['status'] +let deleteStateByWorktreeId: Record = {} + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + deleteStateByWorktreeId, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal, + projectGroups: [], + remoteBranchConflictByWorktreeId: {}, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + removedSshTargetLabels, + settings: null, + sshConnectionStates, + sshStateByEnvironment, + sshTargetLabels, + sshTargetsHydrated: true, + updateWorktreeMeta, + worktreesByRepo, + worktreeCardProperties + }) +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownStartedAt: () => null +})) + +vi.mock('./WorktreeCardAgents', () => ({ + default: () => null +})) + +vi.mock('./use-worktree-activity-status', () => ({ + useWorktreeActivityStatus: () => 'idle' +})) + +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false +})) + +vi.mock('./WorktreeContextMenu', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, + CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', + WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', + WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' +})) + +const FAILURE = "error: failed to delete '/repo/worktrees/one': Operation not permitted" + +function makeRepo(): Repo { + return { id: 'repo-1', path: '/repo', displayName: 'Repo', badgeColor: '#999999', addedAt: 1 } +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'worktree-1', + repoId: 'repo-1', + path: '/repo/worktrees/one', + displayName: 'Workspace one', + branch: 'one', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + ...overrides + } +} + +function renderCard(worktree: Worktree): string { + // Static markup escapes the quotes in Git's message. + return renderToStaticMarkup( + + ).replaceAll(''', "'") +} + +describe('WorktreeCard for a delete that failed partway', () => { + beforeAll(async () => { + WorktreeCard = (await import('./WorktreeCard')).default + }, 20_000) + + beforeEach(() => { + vi.clearAllMocks() + deleteStateByWorktreeId = {} + worktreesByRepo = {} + worktreeCardProperties = ['status'] + }) + + it('says the delete failed, with the error the host lists', () => { + const markup = renderCard(makeWorktree({ removalError: FAILURE })) + + expect(markup).toContain('data-worktree-card-delete-failed') + expect(markup).toContain(`Delete failed: ${FAILURE}`) + }) + + it('shows nothing extra on a normal row', () => { + const markup = renderCard(makeWorktree()) + + expect(markup).not.toContain('data-worktree-card-delete-failed') + expect(markup).not.toContain('Delete failed') + }) + + it('shows Deleting instead once the retry starts', () => { + deleteStateByWorktreeId = { 'worktree-1': { isDeleting: true, error: null } } + + const markup = renderCard(makeWorktree({ removalError: FAILURE })) + + expect(markup).not.toContain('data-worktree-card-delete-failed') + expect(markup).toContain('Deleting') + }) +}) diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index d050d1c076aa..932f00abacb8 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => { displayName: string isMainWorktree: boolean hostId?: ExecutionHostId + removalError?: string } >(), repos: [] as { id: string; displayName: string; connectionId?: string }[], @@ -104,6 +105,7 @@ function setWorktrees( displayName?: string isMainWorktree?: boolean hostId?: ExecutionHostId + removalError?: string }[] ): void { mocks.state.worktreeMap = new Map( @@ -116,7 +118,8 @@ function setWorktrees( path: worktree.path ?? `/workspaces/${worktree.id}`, displayName: worktree.displayName ?? worktree.id, isMainWorktree: worktree.isMainWorktree ?? false, - ...(worktree.hostId ? { hostId: worktree.hostId } : {}) + ...(worktree.hostId ? { hostId: worktree.hostId } : {}), + ...(worktree.removalError ? { removalError: worktree.removalError } : {}) } ]) ) @@ -170,6 +173,36 @@ describe('delete worktree flow', () => { }) }) + it('keeps a failed delete’s host error when Delete opens the dialog, so the dialog shows it', () => { + setWorktrees([{ id: 'wt-1', removalError: 'Operation not permitted' }]) + mocks.state.deleteStateByWorktreeId['wt-1'] = { + isDeleting: false, + error: 'Operation not permitted', + canForceDelete: false + } + + runWorktreeDelete('wt-1') + runWorktreeBatchDelete(['wt-1']) + + expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', expect.anything()) + expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalled() + expect(mocks.state.deleteStateByWorktreeId['wt-1']?.error).toBe('Operation not permitted') + }) + + it('still clears a stale error on a row the host does not list as failed', () => { + setWorktrees([{ id: 'wt-1' }]) + mocks.state.deleteStateByWorktreeId['wt-1'] = { + isDeleting: false, + error: 'Worktree has uncommitted changes', + canForceDelete: true + } + + runWorktreeDelete('wt-1') + + expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('wt-1') + expect(mocks.state.deleteStateByWorktreeId['wt-1']).toBeUndefined() + }) + it('treats duplicate selected ids as one delete target', () => { setWorktrees([{ id: 'wt-1' }]) diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index dafda9e5f5fd..d6f52b2784a4 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -7,6 +7,7 @@ import { showWorkspaceListChangedToast } from './stale-workspace-list-toast' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' +import { resetWorktreeDeleteState } from './worktree-delete-state-reset' import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -63,11 +64,7 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt }) return } - if (target.hostId) { - state.clearWorktreeDeleteState(worktreeId, target.hostId) - } else { - state.clearWorktreeDeleteState(worktreeId) - } + resetWorktreeDeleteState(target, target) // Why: a disconnected SSH host has no provider, so worktrees:remove throws; route to reconnect-and-delete or local-only forget. // Skip on paired web/mobile clients: SSH state is desktop-only, so empty sshTargetLabels misclassifies SSH repos as ghosts; their worktree.rm RPC still handles the delete. @@ -136,11 +133,7 @@ export function runWorktreeBatchDelete( } for (const target of targets) { - if (target.hostId) { - state.clearWorktreeDeleteState(target.id, target.hostId) - } else { - state.clearWorktreeDeleteState(target.id) - } + resetWorktreeDeleteState(target, target) } // Why: bulk cleanup can destroy many directories at once, so batch/Space deletes keep an explicit confirmation step. diff --git a/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx b/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx index c71d9d6b0cbf..5629daf35fca 100644 --- a/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx @@ -32,7 +32,8 @@ export function WorktreeCardSecondaryRows({ compactInlineAgentRows, showLineageChildChip, lineageChildAriaLabel, - childWorkspaceShortLabel + childWorkspaceShortLabel, + isDeleting } = card const { hasMetaRow } = presentation @@ -54,6 +55,23 @@ export function WorktreeCardSecondaryRows({ )} + {/* Why from the row: the host lists a failed delete until it is retried, forgotten or gone. */} + {worktree.removalError && !isDeleting ? ( +
+ + + {translate( + 'auto.components.sidebar.WorktreeCard.deleteFailed', + 'Delete failed: {{error}}', + { error: worktree.removalError } + )} + +
+ ) : null} + {isActive && worktree.linkedLinearIssue ? ( , + row: Pick | null | undefined +): void { + if (row?.removalError) { + return + } + const state = useAppStore.getState() + if (target.hostId) { + state.clearWorktreeDeleteState(target.id, target.hostId) + } else { + state.clearWorktreeDeleteState(target.id) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a453d656b52e..93af55cdf251 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5648,6 +5648,7 @@ "74522ee457": "rename failed", "02e19349f4": "Auto-rename failed: view error", "691ccfd622": "Deleting…", + "deleteFailed": "Delete failed: {{error}}", "35ccfe2475": "Project {{value0}}", "1d66d84f0b": "string", "57eaa61b55": "Hide child workspaces", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 46f75bab84e8..f3add9ab9edd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4679,6 +4679,7 @@ "74522ee457": "falló el cambio de nombre", "02e19349f4": "Error al cambiar el nombre automáticamente: ver error", "691ccfd622": "Eliminando…", + "deleteFailed": "Error al eliminar: {{error}}", "35ccfe2475": "Proyecto {{value0}}", "1d66d84f0b": "cadena", "57eaa61b55": "Ocultar espacios de trabajo secundarios", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 257932e01ada..9c8cf8d3f29e 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -5583,6 +5583,7 @@ "74522ee457": "échec du renommage", "02e19349f4": "Échec du renommage auto : voir l'erreur", "691ccfd622": "Suppression…", + "deleteFailed": "Échec de la suppression : {{error}}", "35ccfe2475": "Projet {{value0}}", "1d66d84f0b": "string", "57eaa61b55": "Masquer les espaces de travail enfants", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cf999fbbb33e..ace712dcf674 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5438,6 +5438,7 @@ "74522ee457": "名前の変更に失敗しました", "02e19349f4": "名前の自動変更に失敗しました: 表示エラー", "691ccfd622": "削除中…", + "deleteFailed": "削除に失敗しました: {{error}}", "35ccfe2475": "プロジェクト{{value0}}", "1d66d84f0b": "文字列", "57eaa61b55": "子ワークスペースを非表示にする", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 58a933015140..62febb2d8e0d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5438,6 +5438,7 @@ "74522ee457": "이름 바꾸기 실패", "02e19349f4": "자동 이름 바꾸기 실패: 보기 오류", "691ccfd622": "삭제 중…", + "deleteFailed": "삭제 실패: {{error}}", "35ccfe2475": "프로젝트 {{value0}}", "1d66d84f0b": "문자열", "57eaa61b55": "하위 워크스페이스 숨기기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 56c27890fe46..6853f6ec8888 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5438,6 +5438,7 @@ "74522ee457": "重命名失败", "02e19349f4": "自动重命名失败:查看错误", "691ccfd622": "正在删除...", + "deleteFailed": "删除失败:{{error}}", "35ccfe2475": "项目{{value0}}", "1d66d84f0b": "字符串", "57eaa61b55": "隐藏子工作区", From e0636290cdcf2db9708e41b6910fe5ef4c5b010d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:06:24 -0700 Subject: [PATCH 41/46] fix(renderer): a failed delete's dialog shows the host's error, and its card label keeps the full error a hover away --- .../sidebar/DeleteWorktreeDialog.test.tsx | 29 ++++++++++------- .../sidebar/DeleteWorktreeDialog.tsx | 16 +++------- .../sidebar/DeleteWorktreeTargetPreview.tsx | 6 ++-- .../WorktreeCard.delete-failed.test.tsx | 6 ++-- .../sidebar/delete-worktree-flow.test.ts | 31 +++++++----------- .../sidebar/delete-worktree-flow.ts | 13 ++++++-- .../sidebar/worktree-card-secondary-rows.tsx | 32 +++++++++++-------- .../sidebar/worktree-delete-error-display.ts | 12 +++++++ .../sidebar/worktree-delete-state-reset.ts | 21 ------------ src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 15 files changed, 88 insertions(+), 90 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-delete-error-display.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-delete-state-reset.ts diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 0cb1c475ed71..c69aff187e2f 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -388,7 +388,7 @@ describe('DeleteWorktreeDialog lineage copy', () => { expect(mocks.state.removeWorktree).not.toHaveBeenCalled() }) - it('shows a failed delete’s host error and keeps it when the dialog is cancelled', async () => { + it('shows the error the host lists for a failed delete, not a stale local one', async () => { const failure = 'Operation not permitted' const workspace = { ...makeWorktree('Failed workspace', '/workspaces/failed'), @@ -396,10 +396,11 @@ describe('DeleteWorktreeDialog lineage copy', () => { } mocks.state.modalData = { worktreeId: workspace.id } mocks.state.allWorktrees.mockReturnValue([workspace]) + // What a lost retry reply leaves behind while the host lists the retry's own failure. mocks.state.deleteStateByWorktreeId = { [workspace.id]: { isDeleting: false, - error: failure, + error: 'Request timed out', canForceDelete: false, forceDeleteReason: null } @@ -407,19 +408,21 @@ describe('DeleteWorktreeDialog lineage copy', () => { const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog') const markup = renderToStaticMarkup() - clickCancel() expect(markup).toContain(failure) - expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalled() - expect(mocks.state.closeModal).toHaveBeenCalled() + expect(markup).not.toContain('Request timed out') }) - it('still clears an ordinary delete error when the dialog is cancelled', async () => { - const workspace = makeWorktree('Dirty workspace', '/workspaces/dirty') - mocks.state.modalData = { worktreeId: workspace.id } - mocks.state.allWorktrees.mockReturnValue([workspace]) + it('shows a failed row’s host error in a batch and clears every stale error on Cancel', async () => { + const failed = { + ...makeWorktree('Failed workspace', '/workspaces/failed'), + removalError: 'Operation not permitted' + } + const dirty = makeWorktree('Dirty workspace', '/workspaces/dirty') + mocks.state.modalData = { worktreeIds: [failed.id, dirty.id] } + mocks.state.allWorktrees.mockReturnValue([failed, dirty]) mocks.state.deleteStateByWorktreeId = { - [workspace.id]: { + [dirty.id]: { isDeleting: false, error: 'Worktree has uncommitted changes', canForceDelete: true, @@ -428,10 +431,12 @@ describe('DeleteWorktreeDialog lineage copy', () => { } const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog') - renderToStaticMarkup() + const markup = renderToStaticMarkup() clickCancel() - expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith(workspace.id) + expect(markup).toContain('Operation not permitted') + expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith(failed.id, undefined) + expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith(dirty.id, undefined) }) it('notifies the dialog caller after a toast force delete succeeds', async () => { diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index c74c5b631d04..8ea17c893bdc 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -9,8 +9,7 @@ import { import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { runWorktreeDeletesInParallel } from './delete-worktree-flow' -import { resetWorktreeDeleteState } from './worktree-delete-state-reset' -import type { WorktreeDeleteIdentity } from './worktree-delete-request' +import { getWorktreeDeleteErrorToShow } from './worktree-delete-error-display' import { composeWorktreeHostIdentity, getWorktreeHostIdentity @@ -170,7 +169,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { ? getDeleteStateForWorktreeHost(worktree, deleteStateByWorktreeId) : undefined const isDeleting = deleteStates.some((state) => state.isDeleting) - const deleteError = !isBatchDelete ? (deleteState?.error ?? null) : null + const deleteError = !isBatchDelete ? getWorktreeDeleteErrorToShow(worktree, deleteState) : null const canForceDelete = !isBatchDelete && (deleteState?.canForceDelete ?? false) const gitStatusByWorktreeIdentity = useDeleteWorktreeStatusHydration({ isOpen, @@ -228,23 +227,18 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const currentState = currentTarget ? getDeleteStateForWorktreeHost(currentTarget, state) : undefined - const rowOf = (target: Pick) => - worktrees.find( - (item) => item.id === target.id && (!target.hostId || item.hostId === target.hostId) - ) if (isBatchDelete) { for (const target of deleteStateTargets) { if (!getDeleteStateForWorktreeHost(target, state)?.isDeleting) { - resetWorktreeDeleteState(target, rowOf(target)) + clearWorktreeDeleteState(target.id, target.hostId) } } } else if (worktreeId && !currentState?.isDeleting) { - const target = { id: worktreeId, hostId: currentTarget?.hostId } - resetWorktreeDeleteState(target, rowOf(target)) + clearWorktreeDeleteState(worktreeId, currentTarget?.hostId) } closeModal() }, - [closeModal, deleteStateTargets, isBatchDelete, worktreeId, worktrees] + [clearWorktreeDeleteState, closeModal, deleteStateTargets, isBatchDelete, worktreeId] ) const persistDontAskAgainPreference = useCallback((): void => { diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx index fa5a47715caf..16004a9ec7a7 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.tsx @@ -6,6 +6,7 @@ import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualif import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint' import type { AppState } from '@/store/types' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' +import { getWorktreeDeleteErrorToShow } from './worktree-delete-error-display' import { getExecutionHostLabel, parseExecutionHostId, @@ -61,6 +62,7 @@ export function DeleteWorktreeTargetPreview({
{worktrees.map((item, index) => { const itemDeleteState = getDeleteStateForWorktreeHost(item, deleteStateByWorktreeId) + const itemDeleteError = getWorktreeDeleteErrorToShow(item, itemDeleteState) const labelIds = { name: `${targetIdPrefix}-${index}-name`, path: `${targetIdPrefix}-${index}-path`, @@ -92,9 +94,9 @@ export function DeleteWorktreeTargetPreview({ item.hostId ? getWorktreeHostIdentity(item) : item.id )} /> - {itemDeleteState?.error ? ( + {itemDeleteError ? (
- {itemDeleteState.error} + {itemDeleteError}
) : null}
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx index 93eb0afe5b27..4628e5686029 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx @@ -133,11 +133,13 @@ describe('WorktreeCard for a delete that failed partway', () => { worktreeCardProperties = ['status'] }) - it('says the delete failed, with the error the host lists', () => { + it('says the delete failed, with the full error the host lists one hover away', () => { const markup = renderCard(makeWorktree({ removalError: FAILURE })) expect(markup).toContain('data-worktree-card-delete-failed') - expect(markup).toContain(`Delete failed: ${FAILURE}`) + expect(markup).toContain('Delete failed') + // The tooltip primitive is rendered inline by this harness. + expect(markup).toContain(FAILURE) }) it('shows nothing extra on a normal row', () => { diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index 932f00abacb8..ef9970e4c202 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -173,34 +173,27 @@ describe('delete worktree flow', () => { }) }) - it('keeps a failed delete’s host error when Delete opens the dialog, so the dialog shows it', () => { - setWorktrees([{ id: 'wt-1', removalError: 'Operation not permitted' }]) - mocks.state.deleteStateByWorktreeId['wt-1'] = { + it('clears stale delete errors for a mixed batch before its dialog opens', () => { + setWorktrees([{ id: 'wt-failed', removalError: 'Operation not permitted' }, { id: 'wt-dirty' }]) + mocks.state.deleteStateByWorktreeId['wt-failed'] = { isDeleting: false, - error: 'Operation not permitted', + error: 'Request timed out', canForceDelete: false } - - runWorktreeDelete('wt-1') - runWorktreeBatchDelete(['wt-1']) - - expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', expect.anything()) - expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalled() - expect(mocks.state.deleteStateByWorktreeId['wt-1']?.error).toBe('Operation not permitted') - }) - - it('still clears a stale error on a row the host does not list as failed', () => { - setWorktrees([{ id: 'wt-1' }]) - mocks.state.deleteStateByWorktreeId['wt-1'] = { + mocks.state.deleteStateByWorktreeId['wt-dirty'] = { isDeleting: false, error: 'Worktree has uncommitted changes', canForceDelete: true } - runWorktreeDelete('wt-1') + expect(runWorktreeBatchDelete(['wt-failed', 'wt-dirty'])).toBe(true) - expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('wt-1') - expect(mocks.state.deleteStateByWorktreeId['wt-1']).toBeUndefined() + expect(mocks.state.openModal).toHaveBeenCalledWith( + 'delete-worktree', + expect.objectContaining({ worktreeIds: ['wt-failed', 'wt-dirty'] }) + ) + // The failed row's own error still shows in the dialog: it comes from the row. + expect(mocks.state.deleteStateByWorktreeId).toEqual({}) }) it('treats duplicate selected ids as one delete target', () => { diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index d6f52b2784a4..dafda9e5f5fd 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -7,7 +7,6 @@ import { showWorkspaceListChangedToast } from './stale-workspace-list-toast' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' -import { resetWorktreeDeleteState } from './worktree-delete-state-reset' import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -64,7 +63,11 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt }) return } - resetWorktreeDeleteState(target, target) + if (target.hostId) { + state.clearWorktreeDeleteState(worktreeId, target.hostId) + } else { + state.clearWorktreeDeleteState(worktreeId) + } // Why: a disconnected SSH host has no provider, so worktrees:remove throws; route to reconnect-and-delete or local-only forget. // Skip on paired web/mobile clients: SSH state is desktop-only, so empty sshTargetLabels misclassifies SSH repos as ghosts; their worktree.rm RPC still handles the delete. @@ -133,7 +136,11 @@ export function runWorktreeBatchDelete( } for (const target of targets) { - resetWorktreeDeleteState(target, target) + if (target.hostId) { + state.clearWorktreeDeleteState(target.id, target.hostId) + } else { + state.clearWorktreeDeleteState(target.id) + } } // Why: bulk cleanup can destroy many directories at once, so batch/Space deletes keep an explicit confirmation step. diff --git a/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx b/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx index 5629daf35fca..d661726c67fb 100644 --- a/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx @@ -55,21 +55,25 @@ export function WorktreeCardSecondaryRows({ )} - {/* Why from the row: the host lists a failed delete until it is retried, forgotten or gone. */} + {/* Why from the row: the host lists a failed delete until it is retried, forgotten or gone. + Why a tooltip: the error leads with the path; the Delete dialog shows it inline too. */} {worktree.removalError && !isDeleting ? ( -
- - - {translate( - 'auto.components.sidebar.WorktreeCard.deleteFailed', - 'Delete failed: {{error}}', - { error: worktree.removalError } - )} - -
+ + +
+ + + {translate('auto.components.sidebar.WorktreeCard.deleteFailed', 'Delete failed')} + +
+
+ + {worktree.removalError} + +
) : null} {isActive && worktree.linkedLinearIssue ? ( diff --git a/src/renderer/src/components/sidebar/worktree-delete-error-display.ts b/src/renderer/src/components/sidebar/worktree-delete-error-display.ts new file mode 100644 index 000000000000..f4697df6b72f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-delete-error-display.ts @@ -0,0 +1,12 @@ +import type { Worktree } from '../../../../shared/worktree/types' + +/** + * The delete error to show for a row. A failed delete the host lists wins over renderer state, so + * every view shows the same error as the card, and a stale local error cannot hide it. + */ +export function getWorktreeDeleteErrorToShow( + row: Pick | null | undefined, + state: { isDeleting: boolean; error: string | null } | undefined +): string | null { + return row?.removalError && !state?.isDeleting ? row.removalError : (state?.error ?? null) +} diff --git a/src/renderer/src/components/sidebar/worktree-delete-state-reset.ts b/src/renderer/src/components/sidebar/worktree-delete-state-reset.ts deleted file mode 100644 index bc5835ca6252..000000000000 --- a/src/renderer/src/components/sidebar/worktree-delete-state-reset.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useAppStore } from '@/store' -import type { Worktree } from '../../../../shared/worktree/types' - -/** - * Drops a row's stale delete state before a new attempt, or when its Delete dialog closes. A failed - * delete the host still lists stays shown: it clears once the retry starts or the host drops it. - */ -export function resetWorktreeDeleteState( - target: Pick, - row: Pick | null | undefined -): void { - if (row?.removalError) { - return - } - const state = useAppStore.getState() - if (target.hostId) { - state.clearWorktreeDeleteState(target.id, target.hostId) - } else { - state.clearWorktreeDeleteState(target.id) - } -} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fa735e9e26bb..77e78dc7ddf0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5650,7 +5650,7 @@ "74522ee457": "rename failed", "02e19349f4": "Auto-rename failed: view error", "691ccfd622": "Deleting…", - "deleteFailed": "Delete failed: {{error}}", + "deleteFailed": "Delete failed", "35ccfe2475": "Project {{value0}}", "1d66d84f0b": "string", "57eaa61b55": "Hide child workspaces", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9143ff68bdb9..354dae452886 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4679,7 +4679,7 @@ "74522ee457": "falló el cambio de nombre", "02e19349f4": "Error al cambiar el nombre automáticamente: ver error", "691ccfd622": "Eliminando…", - "deleteFailed": "Error al eliminar: {{error}}", + "deleteFailed": "Error al eliminar", "35ccfe2475": "Proyecto {{value0}}", "1d66d84f0b": "cadena", "57eaa61b55": "Ocultar espacios de trabajo secundarios", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c219fb9d0ffa..60ea7c2e4ee2 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -5583,7 +5583,7 @@ "74522ee457": "échec du renommage", "02e19349f4": "Échec du renommage auto : voir l'erreur", "691ccfd622": "Suppression…", - "deleteFailed": "Échec de la suppression : {{error}}", + "deleteFailed": "Échec de la suppression", "35ccfe2475": "Projet {{value0}}", "1d66d84f0b": "string", "57eaa61b55": "Masquer les espaces de travail enfants", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9dbf7fa29eb8..a70f8e93a69f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5438,7 +5438,7 @@ "74522ee457": "名前の変更に失敗しました", "02e19349f4": "名前の自動変更に失敗しました: 表示エラー", "691ccfd622": "削除中…", - "deleteFailed": "削除に失敗しました: {{error}}", + "deleteFailed": "削除に失敗しました", "35ccfe2475": "プロジェクト{{value0}}", "1d66d84f0b": "文字列", "57eaa61b55": "子ワークスペースを非表示にする", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 17ccc0dcf843..4109ff68e071 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5438,7 +5438,7 @@ "74522ee457": "이름 바꾸기 실패", "02e19349f4": "자동 이름 바꾸기 실패: 보기 오류", "691ccfd622": "삭제 중…", - "deleteFailed": "삭제 실패: {{error}}", + "deleteFailed": "삭제 실패", "35ccfe2475": "프로젝트 {{value0}}", "1d66d84f0b": "문자열", "57eaa61b55": "하위 워크스페이스 숨기기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 54bf0aef69b5..400c44b71bf2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5438,7 +5438,7 @@ "74522ee457": "重命名失败", "02e19349f4": "自动重命名失败:查看错误", "691ccfd622": "正在删除...", - "deleteFailed": "删除失败:{{error}}", + "deleteFailed": "删除失败", "35ccfe2475": "项目{{value0}}", "1d66d84f0b": "字符串", "57eaa61b55": "隐藏子工作区", From 452ba2d86cc39f094c3bca00561cae6603221a82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:02:20 -0700 Subject: [PATCH 42/46] style(worktrees): import the removal result types in one statement --- .../runtime/orca-runtime-resolve-worktree-removal-target.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 9f4870fa912e..06474b340f66 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -13,7 +13,10 @@ import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../worktree-removal-repo import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' import { closeClientHostedBrowserPagesForWorktree } from './worktree-browser-client-page-close' -import type { ForceDeleteWorktreeBranchResult } from '../../shared/worktree/create-types' +import type { + ForceDeleteWorktreeBranchResult, + RemoveWorktreeResult +} from '../../shared/worktree/create-types' import type { RuntimeTerminalRename } from '../../shared/runtime-types' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' import type { TerminalCreateOptions } from './runtime-terminal-contracts' @@ -27,7 +30,6 @@ import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-i import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../shared/execution-host' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' -import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { resumeInterruptedWorktreeRemovals, retryFailedWorktreeRemoval, From 754a4463d83fb63379370ced5380d3af7d171323 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:25:33 -0700 Subject: [PATCH 43/46] test(worktrees): a runtime listing right after a failed delete shows the failed row, not the cached scan --- .../worktree-removal-failed-retry.spec.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index c17c3ced2235..a875bc30336c 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -11,6 +11,7 @@ import { mkdtemp, removeWorktree, rm, + scanLocalRepoWorktreesForResolutionMock, tmpdir } from '../orca-runtime-test-mocks.spec' import { TEST_REPO_ID, TEST_REPO_PATH } from '../orca-runtime-test-fixtures.spec' @@ -141,3 +142,65 @@ describe('runtime Delete on a failed delete’s leftover', () => { expect(existsSync(leftover)).toBe(false) }) }) + +describe('runtime listing straight after a delete fails partway', () => { + let directory = '' + let leftover = '' + let leftoverId = '' + + beforeEach(async () => { + vi.clearAllMocks() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + directory = await realpath(await mkdtemp(join(tmpdir(), 'orca-runtime-failed-listing-'))) + leftover = join(directory, 'feature') + leftoverId = `${TEST_REPO_ID}::${leftover}` + await mkdir(join(leftover, 'node_modules'), { recursive: true }) + await loadWorktreeRemovalRecords(directory) + }) + + afterEach(async () => { + _resetPendingWorktreeRemovalsForTests() + vi.restoreAllMocks() + await rm(directory, { recursive: true, force: true }) + }) + + it('shows the failed row with its error, not the scan cached before the delete', async () => { + const registered = { + path: leftover, + head: 'abc', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + const gitLists = (worktrees: (typeof registered)[]): void => { + vi.mocked(listWorktreesStrict).mockResolvedValue(worktrees) + scanLocalRepoWorktreesForResolutionMock.mockResolvedValue({ ok: true, worktrees }) + } + gitLists([registered]) + const runtime = createWorktreeRemovalRuntime() + const listLeftover = async () => + (await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`)).worktrees.find( + (row) => row.id === leftoverId + ) + // Caches Git's registration for the 30 s scan TTL. + expect(await listLeftover()).not.toHaveProperty('removalError') + vi.mocked(removeWorktree).mockImplementation(async () => { + // Git drops the registration, then fails on a file it cannot delete. + gitLists([]) + throw new Error(FAILURE) + }) + + await expect( + runtime.removeManagedWorktree(`id:${leftoverId}`, { + force: true, + waitForBackgroundRemoval: true + }) + ).rejects.toThrow(/Operation not permitted/) + await _settlePendingWorktreeRemovalsForTests() + + expect(await listLeftover()).toMatchObject({ + path: leftover, + removalError: expect.stringMatching(/Operation not permitted/) + }) + }) +}) From 88e4848211d09d7e6ae27f5684f98dd3f171d472 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:18:51 -0700 Subject: [PATCH 44/46] fix(worktrees): pass the runtime retry's PTY-stop waiver in the shape the waiver invariant pins --- .../runtime/orca-runtime-resolve-worktree-removal-target.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 06474b340f66..71b919bbf1b9 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -88,13 +88,14 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith return false } const hostId = parseExecutionHostId(options.hostId)?.id + const allowUnverifiedPtyStop = options.allowUnverifiedPtyStop === true return retryFailedRemovalUnlessRegistered(target.id, target.path, registeredWorktrees, () => retryFailedWorktreeRemoval(target.id, hostId, (record) => interruptedLocalWorktreeRemovalJob(record, { ...this.localRemovalJobHost(store), stopPtys: () => this.stopPtysForDestructiveWorktreeRemoval(record.worktreeId, { - allowUnverifiedStop: options.allowUnverifiedPtyStop === true + allowUnverifiedStop: allowUnverifiedPtyStop }) }) ) From 9b847b73cd445f1bcd9750e0994090d5d54d3a13 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:57:41 -0700 Subject: [PATCH 45/46] fix(worktrees): drop Remove from Orca from failed-delete rows A failed delete stays listed with its error and Delete retries it; the separate forget item, its dialog copy and forget's failed-record clearing are removed. The startup clear for repos no longer in Orca keeps matching the local copy only. --- src/main/ipc/worktrees-failed-removal.test.ts | 23 +------ .../register-worktree-forget-handlers.ts | 6 -- ...time-failed-local-worktree-removal.test.ts | 13 +--- src/main/worktree-failed-removal.test.ts | 16 +---- src/main/worktree-removal-table.ts | 18 ++--- .../ForgetFailedDeleteMenuItem.test.ts | 69 ------------------- .../sidebar/ForgetFailedDeleteMenuItem.tsx | 63 ----------------- ...rkspaceDialog.failed-local-delete.test.tsx | 60 ---------------- .../sidebar/ForgetSshWorkspaceDialog.tsx | 68 +++++++----------- .../sidebar/WorktreeContextMenuView.tsx | 4 -- src/renderer/src/i18n/locales/en.json | 2 - src/renderer/src/i18n/locales/es.json | 2 - src/renderer/src/i18n/locales/fr.json | 2 - src/renderer/src/i18n/locales/ja.json | 2 - src/renderer/src/i18n/locales/ko.json | 2 - src/renderer/src/i18n/locales/zh.json | 2 - .../worktrees/teardown/remove-worktree.ts | 2 +- .../src/web/preload-api/web-worktrees-api.ts | 2 +- 18 files changed, 38 insertions(+), 318 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts delete mode 100644 src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx delete mode 100644 src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.failed-local-delete.test.tsx diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index 201cca49eba6..f1cfa13eb803 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -1,6 +1,6 @@ // Desktop IPC for a delete that failed after Git dropped the registration: the leftover stays in -// `worktrees:list` with the error, Delete retries it, and forget ends it. Git and the disk are -// mocked; runtime-failed-local-worktree-removal.test.ts runs the real thing. +// `worktrees:list` with the error, Delete retries it. Git and the disk are mocked; +// runtime-failed-local-worktree-removal.test.ts runs the real thing. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { killAllProcessesForWorktreeMock, @@ -8,7 +8,6 @@ import { removeWorktreeMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' -import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { mockKnownFeatureWorktree } from './worktrees-test-fixtures' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { Worktree } from '../../shared/worktree/types' @@ -156,11 +155,9 @@ async function failAfterGitDroppedIt(): Promise { } describe('a failed delete Git no longer registers, over desktop IPC', () => { - let runtime: WorktreeRuntimeStub - beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}) - runtime = setupWorktreeHandlers() + setupWorktreeHandlers() }) afterEach(() => { @@ -252,18 +249,4 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { listWorktreesMock.mockResolvedValue([mockKnownFeatureWorktree()[0]]) expect(await listFeature()).toBeUndefined() }) - - it('forget drops it from Orca without deleting anything', async () => { - await failAfterGitDroppedIt() - - // As the sidebar's Remove from Orca sends it for a local row. - await handlers['worktrees:forgetLocal'](null, { worktreeId: featureId, hostId: 'local' }) - - expect(await listFeature()).toBeUndefined() - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(featureId, 'local') - // Paired clients list the failed row too, so they must hear it is gone. - expect(runtime.notifyWorktreesChangedForRemoteClients).toHaveBeenCalledWith('repo-1') - expect(finishUnregisteredWorktreeRemoval).not.toHaveBeenCalled() - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts index 1d3ad9787005..0c97dda68bd4 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts @@ -35,7 +35,6 @@ import { preservedBranchCleanupByScope } from './preserved-branch-cleanup' import type { WorktreeIpcContext } from '../worktree-ipc-context' -import { forgetFailedWorktreeRemoval } from '../../../worktree-removal-table' export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): void { const { mainWindow, store, runtime, worktreeRemovalsInFlight } = context @@ -118,9 +117,6 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi }) runtime.clearOptimisticReconcileToken(args.worktreeId) - if (!ownerHost || ownerHost.kind === 'local') { - forgetFailedWorktreeRemoval(args.worktreeId) - } // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. removeWorktreeMetadataAndTransientState( store, @@ -142,8 +138,6 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi } } notifyWorktreesChanged(mainWindow, repoId) - // Why: paired clients list this host's failed rows too, and refetch only on this event. - runtime.notifyWorktreesChangedForRemoteClients(repoId) return {} })() worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget }) diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 6ef584d58027..2aad80e10915 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -38,7 +38,6 @@ import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from '../worktree-removal-records' -import { forgetFailedWorktreeRemoval } from '../worktree-removal-table' import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' vi.mock('../project-runtime-git-options', () => ({ @@ -253,16 +252,6 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await listedRows()).toEqual([]) }) - it('forgetting the workspace ends the record and keeps the files', async () => { - await failInSession() - - forgetFailedWorktreeRemoval(worktreeId) - - await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([])) - expect(existsSync(lockedFile)).toBe(true) - expect(await listedRows()).toEqual([]) - }) - it('the record ends once the checkout is deleted outside Orca', async () => { await failInSession() await setImmutable(false) @@ -334,7 +323,7 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await isRegistered(nested)).toBe(true) expect(removeHostTree).not.toHaveBeenCalled() expect(purged).toEqual([]) - // The row keeps the refusal, so the user can move the nested worktree or forget this one. + // The row keeps the refusal, so the user can move the nested worktree and Delete again. expect(await listedRows()).toContainEqual({ path: worktreePath, removalError: expect.stringMatching(/contains another registered worktree/) diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index b60d18e306a2..adedc8ddccab 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -1,5 +1,5 @@ // A delete that fails after Git dropped the checkout's registration: the leftover stays listed with -// the error until Delete retries it, the checkout disappears, or the user forgets it. Git is mocked +// the error until Delete retries it, the checkout disappears, or its repo leaves Orca. Git is mocked // here so this runs on every platform; the real-Git version is in // runtime/runtime-failed-local-worktree-removal.test.ts. import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' @@ -25,7 +25,6 @@ import { withUnregisteredRemovalCheckouts } from './worktree-removal-listing' import { readWorktreeRemovalRecords } from './worktree-removal-records' -import { forgetFailedWorktreeRemoval } from './worktree-removal-table' import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-records-load' vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) @@ -230,19 +229,6 @@ describe('a delete that fails after Git dropped the registration', () => { expect(retryFailedWorktreeRemoval(worktreeId, 'ssh:box', vi.fn())).toBeUndefined() }) - it('ends when the user forgets the workspace, leaving the files', async () => { - await failRemoval() - - forgetFailedWorktreeRemoval(worktreeId) - - await vi.waitFor(async () => - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) - ) - expect(await listRows()).toEqual([mainWorktree]) - expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() - expect(await readdir(checkout)).toEqual(['node_modules']) - }) - it('ends at the next listing once the checkout is deleted outside Orca', async () => { await failRemoval() await rm(checkout, { recursive: true }) diff --git a/src/main/worktree-removal-table.ts b/src/main/worktree-removal-table.ts index e312949bf174..8c901bc9e37b 100644 --- a/src/main/worktree-removal-table.ts +++ b/src/main/worktree-removal-table.ts @@ -8,8 +8,8 @@ import type { GitWorktreeInfo } from '../shared/worktree/types' // The accepted removals, mirrored to disk on every change; listings and joins read only this. export const pendingWorktreeRemovals = new Map() // Deletes that failed after Git dropped the registration: listed with their error until Delete -// retries them, the checkout disappears or is replaced, or the user forgets the workspace. Never -// retried unasked. +// retries them, the checkout disappears or is replaced, or the repo leaves Orca. Never retried +// unasked. export const failedWorktreeRemovals = new Map() // Why weak: a listing that read Git before a delete finished holds the record until it replies. export const finishedWorktreeRemovals = new WeakSet() @@ -43,16 +43,6 @@ export async function worktreeCheckoutExists(worktreePath: string): Promise Promise | undefined ): boolean { if (registeredWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, worktreePath))) { - forgetFailedWorktreeRemoval(worktreeId) + if (failedWorktreeRemovals.delete(worktreeId)) { + void persistWorktreeRemovalRecords() + } return false } return retry() !== undefined diff --git a/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts b/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts deleted file mode 100644 index bfaf9c481956..000000000000 --- a/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - LOCAL_EXECUTION_HOST_ID, - toRuntimeExecutionHostId, - toSshExecutionHostId -} from '../../../../shared/execution-host' -import { canForgetFailedLocalDelete } from './ForgetFailedDeleteMenuItem' - -vi.mock('@/store', () => ({ useAppStore: vi.fn() })) - -const failedRow = { - id: 'repo-1::/workspace/feature-wt', - displayName: 'feature-wt', - removalError: "error: failed to delete '/workspace/feature-wt': Operation not permitted" -} -const localRepo = { connectionId: null } - -describe('canForgetFailedLocalDelete', () => { - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('offers Remove from Orca on a local row whose delete failed', () => { - expect(canForgetFailedLocalDelete(failedRow, localRepo)).toBe(true) - expect( - canForgetFailedLocalDelete({ ...failedRow, hostId: LOCAL_EXECUTION_HOST_ID }, localRepo) - ).toBe(true) - }) - - it('trusts a local row’s own host over a same-id SSH copy of its repo', () => { - expect( - canForgetFailedLocalDelete( - { ...failedRow, hostId: LOCAL_EXECUTION_HOST_ID }, - { connectionId: 'box' } - ) - ).toBe(true) - }) - - it('is not offered on a row without a failed delete', () => { - expect(canForgetFailedLocalDelete({ ...failedRow, removalError: undefined }, localRepo)).toBe( - false - ) - }) - - it('is not offered on rows another process owns', () => { - expect( - canForgetFailedLocalDelete({ ...failedRow, hostId: toSshExecutionHostId('box') }, localRepo) - ).toBe(false) - expect(canForgetFailedLocalDelete(failedRow, { connectionId: 'box' })).toBe(false) - expect( - canForgetFailedLocalDelete( - { - ...failedRow, - hostId: toRuntimeExecutionHostId('env-1'), - runtimeOwnerEnvironmentId: 'env-1' - }, - localRepo - ) - ).toBe(false) - expect( - canForgetFailedLocalDelete({ ...failedRow, runtimeOwnerEnvironmentId: 'env-1' }, localRepo) - ).toBe(false) - }) - - it('is not offered in a paired web client, which cannot forget', () => { - vi.stubGlobal('__ORCA_WEB_CLIENT__', true) - expect(canForgetFailedLocalDelete(failedRow, localRepo)).toBe(false) - }) -}) diff --git a/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx b/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx deleted file mode 100644 index 75fb7a8e7b19..000000000000 --- a/src/renderer/src/components/sidebar/ForgetFailedDeleteMenuItem.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { CircleX } from 'lucide-react' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' -import { useAppStore } from '@/store' -import { translate } from '@/i18n/i18n' -import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' -import { parseExecutionHostId } from '../../../../shared/execution-host' -import type { Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' - -type FailedDeleteRow = Pick< - Worktree, - 'id' | 'displayName' | 'hostId' | 'removalError' | 'runtimeOwnerEnvironmentId' -> - -/** - * A local row whose delete failed partway: Delete retries it, this removes it from Orca only. Only - * rows this window's own main process lists qualify, since that process holds the failed delete. - */ -export function canForgetFailedLocalDelete( - worktree: FailedDeleteRow, - repo: Pick | null | undefined -): boolean { - return ( - Boolean(worktree.removalError) && - // Why the repo only without a hostId: a repo looked up by id may be another host's copy. - (worktree.hostId - ? parseExecutionHostId(worktree.hostId)?.kind === 'local' - : !repo?.connectionId) && - !worktree.runtimeOwnerEnvironmentId && - !isPairedWebClientWindow() - ) -} - -export function ForgetFailedDeleteMenuItem({ - worktree, - repo, - disabled -}: { - worktree: FailedDeleteRow - repo: Pick | null | undefined - disabled: boolean -}): React.JSX.Element | null { - const openModal = useAppStore((s) => s.openModal) - if (!canForgetFailedLocalDelete(worktree, repo)) { - return null - } - return ( - - openModal('forget-ssh-workspace', { - worktreeId: worktree.id, - displayName: worktree.displayName, - resolution: { kind: 'not-ssh' }, - ...(worktree.hostId ? { executionHostId: worktree.hostId } : {}) - }) - } - > - - {translate('auto.components.sidebar.ForgetSshWorkspaceDialog.forget', 'Remove from Orca')} - - ) -} diff --git a/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.failed-local-delete.test.tsx b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.failed-local-delete.test.tsx deleted file mode 100644 index 7a52e5f79ce7..000000000000 --- a/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.failed-local-delete.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -// @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => { - const state = { - modalData: { - worktreeId: 'repo-1::/workspace/feature-wt', - displayName: 'feature-wt', - resolution: { kind: 'not-ssh' }, - executionHostId: 'local' - }, - closeModal: vi.fn(), - removeWorktree: vi.fn(async () => ({ ok: true })), - sshTargetLabels: new Map(), - removedSshTargetLabels: new Map() - } - return { state } -}) - -vi.mock('@/store', () => ({ - useAppStore: Object.assign( - (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), - { getState: () => mocks.state } - ) -})) -vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string, vars?: Record) => - fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => vars?.[name] ?? '') -})) -vi.mock('./delete-worktree-flow', () => ({ runWorktreeDeleteWithToast: vi.fn() })) - -import { ForgetSshWorkspaceDialog } from './ForgetSshWorkspaceDialog' - -describe('ForgetSshWorkspaceDialog for a local row whose delete failed', () => { - afterEach(() => { - cleanup() - }) - - it('offers only Remove from Orca, which forgets the row on its own host', async () => { - render() - - expect(screen.getByText('Remove “feature-wt” from Orca?')).toBeTruthy() - expect( - screen.getByText( - 'Removes this workspace from Orca only. Its remaining files and its branch stay on disk.' - ) - ).toBeTruthy() - expect(screen.queryByText('Reconnect & Delete')).toBeNull() - - fireEvent.click(screen.getByRole('button', { name: 'Remove from Orca' })) - - await waitFor(() => expect(mocks.state.closeModal).toHaveBeenCalled()) - expect(mocks.state.removeWorktree).toHaveBeenCalledWith( - { id: 'repo-1::/workspace/feature-wt', executionHostId: 'local' }, - false, - { mode: 'forget-local' } - ) - }) -}) diff --git a/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx index d9f25aa0b3ae..d9a602f19198 100644 --- a/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx +++ b/src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx @@ -14,7 +14,7 @@ import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { runWorktreeDeleteWithToast } from './delete-worktree-flow' -import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { toSshExecutionHostId } from '../../../../shared/execution-host' import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import type { SshWorkspaceForgetResolution } from './ssh-workspace-forget-resolution' @@ -22,8 +22,6 @@ type ForgetSshWorkspaceModalData = { worktreeId: string displayName: string resolution: SshWorkspaceForgetResolution - /** Only with `not-ssh`: a local row whose delete failed partway (ForgetFailedDeleteMenuItem). */ - executionHostId?: ExecutionHostId } function isForgetModalData(data: unknown): data is ForgetSshWorkspaceModalData { @@ -55,13 +53,12 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null { } const { worktreeId, displayName, resolution } = modalData const canReconnect = resolution.kind === 'disconnected' - const failedLocalDelete = resolution.kind === 'not-ssh' - // An SSH workspace's named target IS the host the removal was confirmed against (STA-4343). + // This dialog only opens for a workspace pinned to a named SSH target, so that + // target IS the host the removal was confirmed against (STA-4343). const removalTarget: WorktreeRemovalTarget = { id: worktreeId, - executionHostId: failedLocalDelete - ? (modalData.executionHostId ?? null) - : toSshExecutionHostId(resolution.targetId) + executionHostId: + resolution.kind === 'not-ssh' ? null : toSshExecutionHostId(resolution.targetId) } const done = (): void => { @@ -101,7 +98,7 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null { } } - // Remove Orca's records only — never touches files, worktrees, or branches. + // Remove Orca's records only — never touches remote files, worktrees, or branches. const handleForget = async (): Promise => { setBusy('forget') try { @@ -135,46 +132,33 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null { - {failedLocalDelete ? null : } - {failedLocalDelete - ? translate( - 'auto.components.sidebar.ForgetSshWorkspaceDialog.removeTitle', - 'Remove “{{name}}” from Orca?', - { name: displayName } - ) - : translate( - 'auto.components.sidebar.ForgetSshWorkspaceDialog.title', - 'Delete “{{name}}”?', - { - name: displayName - } - )} + + {translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.title', + 'Delete “{{name}}”?', + { + name: displayName + } + )} - {failedLocalDelete + {canReconnect ? translate( - 'auto.components.sidebar.ForgetSshWorkspaceDialog.failedDeleteBody', - 'Removes this workspace from Orca only. Its remaining files and its branch stay on disk.' + 'auto.components.sidebar.ForgetSshWorkspaceDialog.disconnectedBody', + 'The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.' ) - : canReconnect - ? translate( - 'auto.components.sidebar.ForgetSshWorkspaceDialog.disconnectedBody', - 'The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.' - ) - : translate( - 'auto.components.sidebar.ForgetSshWorkspaceDialog.ghostBody', - '{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.', - { host: hostLabel } - )} + : translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.ghostBody', + '{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.', + { host: hostLabel } + )} - {failedLocalDelete ? null : ( -
- - {hostLabel} -
- )} +
+ + {hostLabel} +
{/* Why: the ghost-host description already states files are untouched, so only repeat the reassurance on the reconnect (disconnected) path. */} diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx index 7410011de3ca..4e4605f70f69 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx @@ -36,7 +36,6 @@ import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import type { WorktreeContextMenuModel } from './use-worktree-context-menu-model' import { WorktreeStatusMenuItems } from './WorktreeStatusMenuItems' import { WorktreeContextMenuOverlays } from './WorktreeContextMenuOverlays' -import { ForgetFailedDeleteMenuItem } from './ForgetFailedDeleteMenuItem' import { CLOSE_ALL_CONTEXT_MENUS_EVENT, WORKTREE_CONTEXT_MENU_SCOPE_ATTR, @@ -314,9 +313,6 @@ export default function WorktreeContextMenuView({ model }: { model: WorktreeCont onSleep={handleCloseTerminals} onSleepSubtree={handleSleepSubtree} /> - {!isMultiContext ? ( - - ) : null} {/* Why: primary checkout rows can't be git-worktree-removed, so keep a disabled Delete Worktree for parity with non-primary cards and pair it with the enabled Remove Project action below. */} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4ee082019520..2480baed2028 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6326,8 +6326,6 @@ "reconnectFailed": "Reconnection failed", "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", "title": "Delete “{{name}}”?", - "removeTitle": "Remove “{{name}}” from Orca?", - "failedDeleteBody": "Removes this workspace from Orca only. Its remaining files and its branch stay on disk.", "disconnectedBody": "The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.", "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", "cancel": "Cancel", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4f6879587757..55daccc0582c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5358,8 +5358,6 @@ "reconnectFailed": "Error de reconexión", "forgetBody": "Elimina este espacio de trabajo solo de Orca. Los archivos, el worktree de Git y las ramas en {{host}} permanecen intactos.", "title": "¿Eliminar “{{name}}”?", - "removeTitle": "¿Eliminar “{{name}}” de Orca?", - "failedDeleteBody": "Elimina este espacio de trabajo solo de Orca. Los archivos restantes y su rama se conservan en el disco.", "disconnectedBody": "El host SSH de este espacio de trabajo no está conectado. Reconéctalo para eliminarlo también en el remoto, o elimínalo solo de Orca.", "ghostBody": "{{host}} ya no es un host SSH guardado, por lo que este espacio de trabajo ya no está conectado a un host activo. Solo se puede eliminar de Orca — los archivos y ramas en el remoto permanecen intactos.", "cancel": "Cancelar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index e98039b60da9..a23dc2616947 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -6259,8 +6259,6 @@ "reconnectFailed": "Échec de la reconnexion", "forgetBody": "Retire uniquement cet espace de travail d'Orca. Les fichiers, le worktree Git et les branches sur {{host}} restent intacts.", "title": "Supprimer « {{name}} » ?", - "removeTitle": "Retirer « {{name}} » d'Orca ?", - "failedDeleteBody": "Retire uniquement cet espace de travail d'Orca. Les fichiers restants et sa branche sont conservés sur le disque.", "disconnectedBody": "L'hôte SSH de cet espace de travail n'est pas connecté. Reconnectez-le pour le supprimer aussi à distance, ou retirez-le seulement d'Orca.", "ghostBody": "{{host}} n'est plus un hôte SSH enregistré ; cet espace de travail n'est donc plus connecté à un hôte actif. Il ne peut être que retiré d'Orca — les fichiers et branches distants restent intacts.", "cancel": "Annuler", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 64cfc828ec28..f1cd11c77755 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6182,8 +6182,6 @@ "reconnectFailed": "再接続に失敗しました", "forgetBody": "このワークスペースを Orca からのみ削除します。ファイル、Git ワークツリー、{{host}}のブランチはそのまま残ります。", "title": "「{{name}}」を削除しますか?", - "removeTitle": "「{{name}}」を Orca から削除しますか?", - "failedDeleteBody": "このワークスペースを Orca からのみ削除します。残りのファイルとブランチはディスク上にそのまま残ります。", "disconnectedBody": "このワークスペースの SSH ホストが接続されていません。再接続してリモートでも削除するか、Orca からのみ削除してください。", "ghostBody": "{{host}} は保存済みの SSH ホストではなくなったため、このワークスペースは稼働中のホストに接続されていません。Orca から削除することのみ可能です。リモート上のファイルとブランチはそのまま残ります。", "cancel": "キャンセル", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 85eee4c26810..8c332048ffa7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6182,8 +6182,6 @@ "reconnectFailed": "재연결 실패", "forgetBody": "이 작업 공간을 Orca에서만 제거합니다. 파일, Git 워크트리, {{host}}의 브랜치는 그대로 유지됩니다.", "title": "Delete “{{name}}”?", - "removeTitle": "“{{name}}”을(를) Orca에서 제거할까요?", - "failedDeleteBody": "이 작업 공간을 Orca에서만 제거합니다. 남은 파일과 브랜치는 디스크에 그대로 유지됩니다.", "disconnectedBody": "이 워크트리의 SSH 호스트가 연결되어 있지 않습니다. 재연결하여 원격에서도 삭제하거나 Orca에서만 제거하세요.", "ghostBody": "{{host}}은(는) 더 이상 저장된 SSH 호스트가 아니므로 이 워크스페이스는 라이브 호스트에 연결되어 있지 않습니다. Orca에서 제거만 할 수 있으며, 원격의 파일과 브랜치는 그대로 유지됩니다.", "cancel": "취소", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1caed811e42e..dfeff5ba27b9 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6182,8 +6182,6 @@ "reconnectFailed": "重新连接失败", "forgetBody": "仅从 Orca 中移除此工作区。文件、Git 工作树和 {{host}} 上的分支保持不变。", "title": "从 Orca 中移除“{{name}}”?", - "removeTitle": "从 Orca 中移除“{{name}}”?", - "failedDeleteBody": "仅从 Orca 中移除此工作区。剩余的文件及其分支保留在磁盘上。", "disconnectedBody": "此工作区的 SSH 主机未连接。重新连接以同时在远程删除,或仅从 Orca 中移除。", "ghostBody": "{{host}} 不再是已保存的 SSH 主机,因此此工作区不再连接到活跃主机。只能从 Orca 中移除 — 远程文件和分支保持不变。", "cancel": "取消", diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index bd36be49a778..3ca041aeb753 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -114,7 +114,7 @@ export function createRemoveWorktree( if (!forgetLocalOnly) { removalGenerationGuard?.assertCurrent() } - // Why: forget-local clears Orca's records via local IPC regardless of host, reachable or not. + // Why: forget-local clears Orca's records via local IPC regardless of host — the remote is gone or unreachable. const target = getActiveRuntimeTarget( removalRoute ? settingsForWorktreeOperationRoute(get().settings, removalRoute) diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts index 348478443fad..ee626b187547 100644 --- a/src/renderer/src/web/preload-api/web-worktrees-api.ts +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -145,7 +145,7 @@ export function createWorktreesApi(): NonNullable['worktrees worktreeRemovalReplyTimeoutMs(skipArchive !== true) ) }, - // Why: forget-locally clears records only the desktop's own main process holds; a paired web client has none. + // Why: forget-locally clears a desktop workspace pinned to a dead SSH host; a paired web client has no such ghost state. forgetLocal: () => { throw new Error('Forgetting a workspace is unavailable in paired web clients.') }, From 47621c5b60575552cbb536cbecb43d0ca7ab1f1d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:33:24 -0700 Subject: [PATCH 46/46] test(worktrees): wait for the dropped record's write before the failed-removal suite tears down --- src/main/worktree-failed-removal.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index adedc8ddccab..f626e7bd3272 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -286,5 +286,8 @@ describe('a delete that fails after Git dropped the registration', () => { expect(await listRows()).toEqual([mainWorktree]) expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() + await vi.waitFor(async () => + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + ) }) })