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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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,