Handle locked worktree force deletion

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-09 15:08:41 -04:00
co-authored by Orca
parent ff661a7ab2
commit dc8d3e459b
7 changed files with 85 additions and 7 deletions
+5 -5
View File
@@ -247,7 +247,7 @@ branch refs/heads/main
])
})
it('passes --force before the worktree path when forced removal is requested', async () => {
it('passes double --force before the worktree path when forced removal is requested', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
@@ -269,7 +269,7 @@ branch refs/heads/main
await removeWorktree('/repo', '/repo-feature', true)
expect(getGitCalls()).toContain('git worktree remove --force /repo-feature')
expect(getGitCalls()).toContain('git worktree remove --force --force /repo-feature')
})
it('matches Windows worktree paths before deleting the branch', async () => {
@@ -1070,7 +1070,7 @@ branch refs/heads/main
'git sparse-checkout init --cone',
'git sparse-checkout set -- packages/web',
'git config --local --unset-all branch.feature/test.base',
'git worktree remove --force /repo-feature',
'git worktree remove --force --force /repo-feature',
'git branch -D -- feature/test'
])
)
@@ -1078,11 +1078,11 @@ branch refs/heads/main
expectGitCallOrder(
calls,
'git sparse-checkout set -- packages/web',
'git worktree remove --force /repo-feature'
'git worktree remove --force --force /repo-feature'
)
expectGitCallOrder(
calls,
'git worktree remove --force /repo-feature',
'git worktree remove --force --force /repo-feature',
'git branch -D -- feature/test'
)
})
+42
View File
@@ -33,6 +33,39 @@ async function createRepoWithNewlineWorktree(): Promise<{
}
}
async function createRepoWithLockedDeletedWorktree(): Promise<{
repoPath: string
worktreePath: string
}> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-worktree-locked-delete-'))
tempRoots.push(root)
const repoPath = path.join(root, 'repo')
const requestedWorktreePath = path.join(root, 'locked deleted worktree')
execFileSync('git', ['init', '--quiet', repoPath])
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(repoPath, ['config', 'user.email', 'test@example.com'])
git(repoPath, ['config', 'user.name', 'Test User'])
git(repoPath, ['commit', '--allow-empty', '--quiet', '-m', 'initial'])
git(repoPath, [
'worktree',
'add',
'--quiet',
'-b',
'feature/locked-delete',
requestedWorktreePath
])
git(repoPath, ['worktree', 'lock', '--reason', 'orca locked stale repro', requestedWorktreePath])
const worktreePath = await realpath(requestedWorktreePath)
await rm(worktreePath, { recursive: true, force: true })
return {
repoPath: await realpath(repoPath),
worktreePath
}
}
function branchExists(repoPath: string, branchName: string): boolean {
try {
git(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`])
@@ -68,4 +101,13 @@ describe('git worktree paths', () => {
expect(branchExists(repoPath, 'feature/newline')).toBe(false)
}
)
it('force-removes a locked worktree whose directory was deleted manually', async () => {
const { repoPath, worktreePath } = await createRepoWithLockedDeletedWorktree()
await expect(removeWorktree(repoPath, worktreePath, true)).resolves.toEqual({})
expect(git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain(worktreePath)
expect(branchExists(repoPath, 'feature/locked-delete')).toBe(false)
})
})
+3 -1
View File
@@ -1076,7 +1076,9 @@ async function performRemoveWorktree(
const args = ['worktree', 'remove']
if (force) {
args.push('--force')
// Why: Git requires force twice to remove a locked worktree; Orca's
// force path already passed the explicit destructive confirmation gate.
args.push('--force', '--force')
}
args.push(worktreePath)
await gitExecFileAsync(args, gitExecOptions(repoPath, options))
@@ -224,6 +224,10 @@ describe('removeWorktreeOp', () => {
forceBranchDelete: true
})
expect(git).toHaveBeenCalledWith(
['worktree', 'remove', '--force', '--force', '/repo-feature'],
expect.any(String)
)
expect(git).toHaveBeenCalledWith(['branch', '-D', '--', 'feature/test'], expect.any(String))
expect(git).not.toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String))
})
+3 -1
View File
@@ -175,7 +175,9 @@ export async function removeWorktreeOp(
const args = ['worktree', 'remove']
if (force) {
args.push('--force')
// Why: mirror local deletion semantics; Git requires force twice to
// override worktree locks after the user confirmed destructive removal.
args.push('--force', '--force')
}
args.push(worktreePath)
await git(args, repoPath)
@@ -324,6 +324,33 @@ describe('removeWorktree cascade', () => {
})
})
it('offers force delete for locked worktree removal errors', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/workspace/feature-wt'
const error =
"fatal: cannot remove a locked working tree, lock reason: claude session\nuse 'remove -f -f' to override or unlock first"
mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
},
tabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {}
})
const result = await store.getState().removeWorktree(worktreeId)
expect(result).toEqual({ ok: false, error })
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
isDeleting: false,
error,
canForceDelete: true
})
})
it('offers force delete when Git already removed an unregistered worktree', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/workspace/deleted-wt'
@@ -293,6 +293,7 @@ function toVisibleTabType(contentType: string): WorkspaceVisibleTabType {
const FORCE_RETRYABLE_WORKTREE_REMOVAL_MESSAGES = [
'Worktree has uncommitted or untracked changes',
'contains modified or untracked files',
'cannot remove a locked working tree',
'Worktree is no longer registered with Git but its directory remains',
'Worktree is no longer registered with Git and its directory is already gone'
] as const