fix: handle orphaned worktree deletion with disk cleanup (#109)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jinjing 2026-03-25 23:14:48 -07:00 committed by GitHub
parent 69978c93f5
commit 902e2271b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 53 additions and 2 deletions

1
.gitignore vendored
View file

@ -47,6 +47,7 @@ coverage/
tmp/
.tmp/
design-docs/
.context/
# Stably CLI (only docs/ are tracked)
.stably/*

View file

@ -8,7 +8,8 @@ import {
shouldSetDisplayName,
mergeWorktree,
parseWorktreeId,
formatWorktreeRemovalError
formatWorktreeRemovalError,
isOrphanedWorktreeError
} from './worktree-logic'
describe('sanitizeWorktreeName', () => {
@ -254,3 +255,29 @@ describe('formatWorktreeRemovalError', () => {
)
})
})
describe('isOrphanedWorktreeError', () => {
it('returns true when stderr contains "is not a working tree"', () => {
const error = Object.assign(new Error('git failed'), {
stderr: "fatal: '/some/path' is not a working tree"
})
expect(isOrphanedWorktreeError(error)).toBe(true)
})
it('returns true when message contains "is not a working tree"', () => {
const error = new Error("fatal: '/some/path' is not a working tree")
expect(isOrphanedWorktreeError(error)).toBe(true)
})
it('returns false for unrelated git errors', () => {
const error = Object.assign(new Error('git failed'), {
stderr: 'fatal: contains modified or untracked files'
})
expect(isOrphanedWorktreeError(error)).toBe(false)
})
it('returns false for non-Error input', () => {
expect(isOrphanedWorktreeError('string error')).toBe(false)
expect(isOrphanedWorktreeError(null)).toBe(false)
})
})

View file

@ -122,6 +122,19 @@ export function parseWorktreeId(worktreeId: string): { repoId: string; worktreeP
}
}
/**
* Check whether a git error indicates the worktree is no longer tracked by git.
* This happens when a worktree's internal git tracking is removed (e.g. via
* `git worktree prune`) but the directory still exists on disk.
*/
export function isOrphanedWorktreeError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
const msg = (error as { stderr?: string }).stderr || error.message
return /is not a working tree/.test(msg)
}
/**
* Format a human-readable error message for worktree removal failures.
*/

View file

@ -1,6 +1,7 @@
import type { BrowserWindow } from 'electron'
import { ipcMain } from 'electron'
import { execFileSync } from 'child_process'
import { rm } from 'fs/promises'
import type { Store } from '../persistence'
import type { Worktree, WorktreeMeta } from '../../shared/types'
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
@ -14,7 +15,8 @@ import {
shouldSetDisplayName,
mergeWorktree,
parseWorktreeId,
formatWorktreeRemovalError
formatWorktreeRemovalError,
isOrphanedWorktreeError
} from './worktree-logic'
export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store): void {
@ -150,6 +152,14 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
try {
await removeWorktree(repo.path, worktreePath, args.force ?? false)
} catch (error) {
// If git no longer tracks this worktree, clean up the directory and metadata
if (isOrphanedWorktreeError(error)) {
console.warn(`[worktrees] Orphaned worktree detected at ${worktreePath}, cleaning up`)
await rm(worktreePath, { recursive: true, force: true }).catch(() => {})
store.removeWorktreeMeta(args.worktreeId)
notifyWorktreesChanged(mainWindow, repoId)
return
}
throw new Error(formatWorktreeRemovalError(error, worktreePath, args.force ?? false))
}
store.removeWorktreeMeta(args.worktreeId)