fix: prune git worktree tracking after orphaned worktree removal (#428)

When removing an orphaned worktree, `git worktree remove` fails but the
directory is cleaned up via `rm`. Without a subsequent `git worktree prune`,
git's internal tracking remains stale — `git worktree list` continues to
show the entry and the branch stays locked. Add `git worktree prune` after
the rm in both the IPC handler and OrcaRuntimeService to fix this.
This commit is contained in:
Jinjing
2026-04-09 17:46:57 -07:00
committed by GitHub
parent 7a7d287ed2
commit de36c6bafe
3 changed files with 44 additions and 4 deletions
+32 -2
View File
@@ -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: {
+6 -1
View File
@@ -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/<name>`) 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)
+6 -1
View File
@@ -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/<name>`) 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)