diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 6d098fb9079..44b8beaaba2 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -18,7 +18,8 @@ const { hasHooksFileMock, loadHooksMock, computeWorktreePathMock, - ensurePathWithinWorkspaceMock + ensurePathWithinWorkspaceMock, + gitExecFileAsyncMock } = vi.hoisted(() => ({ handleMock: vi.fn(), removeHandlerMock: vi.fn(), @@ -36,7 +37,8 @@ const { hasHooksFileMock: vi.fn(), loadHooksMock: vi.fn(), computeWorktreePathMock: vi.fn(), - ensurePathWithinWorkspaceMock: vi.fn() + ensurePathWithinWorkspaceMock: vi.fn(), + gitExecFileAsyncMock: vi.fn() })) vi.mock('electron', () => ({ @@ -52,6 +54,11 @@ vi.mock('../git/worktree', () => ({ removeWorktree: removeWorktreeMock })) +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn() +})) + vi.mock('../git/repo', () => ({ getGitUsername: getGitUsernameMock, getDefaultBaseRef: getDefaultBaseRefMock, @@ -120,6 +127,7 @@ describe('registerWorktreeHandlers', () => { loadHooksMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, + gitExecFileAsyncMock, mainWindow.webContents.send, store.getRepos, store.getRepo, @@ -350,6 +358,28 @@ describe('registerWorktreeHandlers', () => { }) }) + it('prunes git worktree tracking when removing an orphaned worktree', async () => { + const orphanError = Object.assign(new Error('git worktree remove failed'), { + stderr: "fatal: '/workspace/feature-wt' is not a working tree" + }) + removeWorktreeMock.mockRejectedValue(orphanError) + getEffectiveHooksMock.mockReturnValue(null) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + // Should have called git worktree prune to clean up stale tracking + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: '/workspace/repo' + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/feature-wt') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + }) + it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => { getEffectiveHooksMock.mockReturnValue({ scripts: { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 91235b3cd4c..6fe616aa626 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -12,7 +12,7 @@ import type { import { getPRForBranch } from '../github/client' import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree' import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' -import { gitExecFileSync } from '../git/runner' +import { gitExecFileAsync, gitExecFileSync } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { join } from 'path' import { listRepoWorktrees } from '../repo-worktrees' @@ -242,6 +242,11 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store if (isOrphanedWorktreeError(error)) { console.warn(`[worktrees] Orphaned worktree detected at ${worktreePath}, cleaning up`) await rm(worktreePath, { recursive: true, force: true }).catch(() => {}) + // Why: `git worktree remove` failed, so git's internal worktree tracking + // (`.git/worktrees/`) is still intact. Without pruning, `git worktree + // list` continues to show the stale entry and the branch it had checked out + // remains locked — other worktrees cannot check it out. + await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) store.removeWorktreeMeta(args.worktreeId) await rebuildAuthorizedRootsCache(store) notifyWorktreesChanged(mainWindow, repoId) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ee93c565d56..7dfba77252e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: the Orca runtime is the authoritative live control plane for the CLI, so handle validation, selector resolution, wait state, and summaries are kept together to avoid split-brain behavior. */ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ -import { gitExecFileSync } from '../git/runner' +import { gitExecFileAsync, gitExecFileSync } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' import { randomUUID } from 'crypto' import { join } from 'path' @@ -738,6 +738,11 @@ export class OrcaRuntimeService { } catch (error) { if (isOrphanedWorktreeError(error)) { await rm(worktree.path, { recursive: true, force: true }).catch(() => {}) + // Why: `git worktree remove` failed, so git's internal worktree tracking + // (`.git/worktrees/`) is still intact. Without pruning, `git worktree + // list` continues to show the stale entry and the branch it had checked out + // remains locked — other worktrees cannot check it out. + await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) this.store.removeWorktreeMeta(worktree.id) this.invalidateResolvedWorktreeCache() this.notifier?.worktreesChanged(repo.id)