Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
622b2db
fix(worktrees): a retried or resumed delete only removes the folder i…
brennanb2025 Oct 3, 2026
c387ec9
fix(worktrees): identify the accepted checkout by inode and a real cr…
brennanb2025 Oct 3, 2026
376f7c9
refactor(worktrees): every background removal states the checkout ide…
brennanb2025 Oct 3, 2026
c56fd44
fix(worktrees): a delete that leaves the user's folder in place also …
brennanb2025 Oct 3, 2026
d35ad99
test(worktrees): cover the in-slot folder check when Git cannot remov…
brennanb2025 Oct 3, 2026
c9df7ab
fix(worktrees): a resumed or retried Git delete checks the folder ins…
brennanb2025 Oct 3, 2026
e196709
test(worktrees): assert the second desktop Delete refuses the user's …
brennanb2025 Oct 3, 2026
f523be0
test(worktrees): the desktop Delete test's stand-in host ends the wor…
brennanb2025 Oct 3, 2026
67da9f3
fix(worktrees): a listing asks Git before dropping a failed delete, a…
brennanb2025 Oct 3, 2026
646d79e
fix(worktrees): a failed delete Git cannot be asked about stays faile…
brennanb2025 Oct 3, 2026
91d7652
test(worktrees): the failed-delete record test states that Git still …
brennanb2025 Oct 3, 2026
36b3871
fix(worktrees): a restart keeps a failed delete whose checkout is gon…
brennanb2025 Oct 3, 2026
c3a349b
fix(worktrees): a folder Orca cannot read keeps its failed delete and…
brennanb2025 Oct 3, 2026
09b012e
fix(worktrees): a failed delete leaves the disk only after its worksp…
brennanb2025 Oct 3, 2026
68d6598
fix(worktrees): a runtime Delete of a failed delete's row is told whe…
brennanb2025 Oct 3, 2026
44c2d78
test(worktrees): the desktop Delete test's stand-in host returns nothing
brennanb2025 Oct 3, 2026
122aac4
fix(worktrees): a Delete of a failed delete's leftover whose .git Orc…
brennanb2025 Oct 4, 2026
51cad53
Revert "fix(worktrees): a runtime Delete of a failed delete's row is …
brennanb2025 Oct 4, 2026
a5fc147
fix(worktrees): a failed delete Git could not be asked about is dropp…
brennanb2025 Oct 4, 2026
29de668
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-9338…
brennanb2025 Oct 4, 2026
462b8e9
revert to main before the standard-model rework
brennanb2025 Oct 4, 2026
8086fc5
fix(worktrees): stop deleting Orca-created folders that have no .git …
brennanb2025 Oct 4, 2026
0f99837
fix(worktrees): delete a failed or interrupted workspace only through…
brennanb2025 Oct 4, 2026
c717fdd
test(worktrees): read Git's listing before the Delete call in the rea…
brennanb2025 Oct 4, 2026
f4d6115
docs(worktrees): say when a failed delete's record ends under the Git…
brennanb2025 Oct 4, 2026
5dcbc2c
fix(worktrees): keep main's handling of a startup finish Git refuses,…
brennanb2025 Oct 4, 2026
75d54ef
fix(worktrees): finish a failed delete's branch and push-target remot…
brennanb2025 Oct 4, 2026
4000789
fix(worktrees): unregister an interrupted delete's checkout whose .gi…
brennanb2025 Oct 4, 2026
d7d6128
fix(worktrees): name a worktree Git registers inside a failed delete'…
brennanb2025 Oct 4, 2026
7ffe5a3
fix(worktrees): don't prune other worktrees when a delete fails
brennanb2025 Oct 4, 2026
487aa13
test(worktrees): cover the push-target cleanup every delete job wires…
brennanb2025 Oct 4, 2026
0cc465e
fix(worktrees): say the leftover folder was left on disk instead of w…
brennanb2025 Oct 14, 2026
2c4fa5e
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-9338…
brennanb2025 Oct 14, 2026
1df0592
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-9338…
brennanb2025 Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 0 additions & 58 deletions src/main/git/worktree-git-file-restore.ts

This file was deleted.

33 changes: 21 additions & 12 deletions src/main/git/worktree-removal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,26 +112,35 @@ function deleteBranchOfRemovedWorktree(
}

/**
* 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.
* `assertLeftover` refuses unless the path still holds the removed checkout's own leftover.
* Finishes a removal whose checkout Git no longer registers and is gone from disk: stale admin
* records, then the branch. Deletes no files: Orca removes a checkout only through Git.
* `assertCheckoutGone` refuses while anything is still at the path.
*/
export async function finishUnregisteredWorktreeRemoval(
repoPath: string,
worktreePath: string,
branch: { name: string; head: string } | null,
assertLeftover: () => Promise<void>,
assertCheckoutGone: () => Promise<void>,
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
await assertCheckoutGone()
await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options)).catch(
(error: unknown) => console.warn(`[git] worktree prune failed in ${repoPath}`, error)
)
return deleteBranchOfUnregisteredWorktree(repoPath, worktreePath, branch, options)
}

/**
* The branch step of a removal once Git no longer registers the checkout, whatever is left on disk:
* merged only, as `removeWorktree` does. Prunes nothing and deletes no files.
*/
export async function deleteBranchOfUnregisteredWorktree(
repoPath: string,
worktreePath: string,
branch: { name: string; head: string } | null,
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
try {
await runUnderWorktreeDeleteLimit(async () => {
// Why in the slot: the wait can outlast two large deletes, and the path may change meanwhile.
await assertLeftover()
await 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 {}
}
Expand Down
71 changes: 56 additions & 15 deletions src/main/ipc/worktrees-failed-removal.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
// Desktop IPC for a delete that failed after Git dropped the registration: the leftover stays in
// `worktrees:list` with the error, Delete retries it. Git and the disk are mocked;
// `worktrees:list` with the error; Delete refuses while it is on disk and finishes once it is gone. Git and the disk are mocked;
// runtime-failed-local-worktree-removal.test.ts runs the real thing.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
gitExecFileAsyncMock,
killAllProcessesForWorktreeMock,
listWorktreesMock,
removeWorktreeMock
} from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { mockKnownFeatureWorktree } from './worktrees-test-fixtures'
import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures'
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import type { Worktree } from '../../shared/worktree/types'
import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal'
import { worktreeCheckoutExists } from '../worktree-removal-table'
import type * as WorktreeRemovalModule from '../git/worktree-removal'
import type * as WorktreeRemovalTable from '../worktree-removal-table'
import type * as WorktreeRemovalLeftover from '../worktree-removal-leftover'
Expand Down Expand Up @@ -161,6 +163,38 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => {
afterEach(() => {
_resetPendingWorktreeRemovalsForTests()
vi.mocked(finishUnregisteredWorktreeRemoval).mockClear()
vi.mocked(worktreeCheckoutExists).mockResolvedValue(true)
})

it('removes the push-target remote Orca added when Delete fails partway', async () => {
const [main] = mockKnownFeatureWorktree()
const pushTarget = {
remoteName: 'pr-contributor-orca',
branchName: 'feature',
remoteUrl: 'https://github.com/contributor/orca.git',
remoteCreated: true
}
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget }))
store.getAllWorktreeMeta.mockReturnValue({ [featureId]: makeWorktreeMeta({ pushTarget }) })
gitExecFileAsyncMock.mockImplementation(async (args: string[]) =>
args[0] === 'remote' && args[1] === 'get-url'
? { stdout: `${pushTarget.remoteUrl}\n`, stderr: '' }
: { stdout: '', stderr: '' }
)
removeWorktreeMock.mockImplementation(async () => {
listWorktreesMock.mockResolvedValue([main])
throw new Error(GIT_ERROR)
})

await expect(remove({ worktreeId: featureId })).rejects.toThrow(/Operation not permitted/)
await _settlePendingWorktreeRemovalsForTests()

expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', pushTarget.remoteName], {
cwd: '/workspace/repo'
})
expect(await listFeature()).toMatchObject({
removalError: expect.stringMatching(/Operation not permitted/)
})
})

it('stays in the listing with the error instead of vanishing', async () => {
Expand All @@ -171,13 +205,31 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => {
expect(row?.removing).toBeUndefined()
})

it('Delete runs the recorded removal again: teardown, leftover, branch and metadata', async () => {
it('Delete refuses while the folder is on disk, deleting nothing and keeping the row', async () => {
await failAfterGitDroppedIt()
killAllProcessesForWorktreeMock.mockClear()

for (const force of [false, true]) {
await expect(remove({ worktreeId: featureId, force })).rejects.toThrow(
'/workspace/feature-wt is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace.'
)
}
await _settlePendingWorktreeRemovalsForTests()

expect(finishUnregisteredWorktreeRemoval).not.toHaveBeenCalled()
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
expect(await listFeature()).toMatchObject({ removalError: GIT_ERROR })
})

it('once the user removed the folder, Delete finishes the rest: teardown, branch and metadata', async () => {
await failAfterGitDroppedIt()
killAllProcessesForWorktreeMock.mockClear()
vi.mocked(worktreeCheckoutExists).mockResolvedValue(false)

await expect(remove({ worktreeId: featureId })).resolves.not.toHaveProperty('removing')

// Git has no registration to delete by; the leftover goes through Orca's own delete.
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(finishUnregisteredWorktreeRemoval).toHaveBeenCalledWith(
'/workspace/repo',
Expand All @@ -194,17 +246,6 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => {
expect(await listFeature()).toBeUndefined()
})

it('keeps the row with the new error when the retry fails again', async () => {
await failAfterGitDroppedIt()
vi.mocked(finishUnregisteredWorktreeRemoval).mockRejectedValueOnce(new Error('EPERM again'))

await expect(remove({ worktreeId: featureId })).rejects.toThrow('EPERM again')
await _settlePendingWorktreeRemovalsForTests()

expect(await listFeature()).toMatchObject({ removalError: 'EPERM again' })
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})

it('joins a retry another client started while this Delete listed Git', async () => {
await failAfterGitDroppedIt()
const [main] = mockKnownFeatureWorktree()
Expand Down
36 changes: 14 additions & 22 deletions src/main/ipc/worktrees-orphan-directory-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
Expand Down Expand Up @@ -285,13 +285,13 @@ describe('registerWorktreeHandlers', () => {
}
})

it('prompts then force-removes an Orca-created unregistered leftover directory with no git marker', async () => {
it('refuses an Orca-created unregistered directory with no git marker, even forced, until the user removes it', async () => {
const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-leftover-'))
const repoPath = join(parentDir, 'repo')
const leftoverPath = join(parentDir, 'leftover')
const worktreeId = `repo-1::${leftoverPath}`
await mkdir(leftoverPath, { recursive: true })
await writeFile(join(leftoverPath, 'leftover.txt'), 'kept until force\n')
await writeFile(join(leftoverPath, 'notes.txt'), 'mine\n')
store.getRepo.mockReturnValue({
id: 'repo-1',
path: repoPath,
Expand All @@ -312,30 +312,22 @@ describe('registerWorktreeHandlers', () => {
})

try {
await expect(handlers['worktrees:remove'](null, { worktreeId })).rejects.toThrow(
'Worktree is no longer registered with Git but its directory remains.'
)
await expect(lstat(leftoverPath)).resolves.toBeTruthy()
for (const force of [false, true]) {
await expect(handlers['worktrees:remove'](null, { worktreeId, force })).rejects.toThrow(
`Refusing to delete unregistered worktree path: ${leftoverPath}`
)
}
await expect(readFile(join(leftoverPath, 'notes.txt'), 'utf8')).resolves.toBe('mine\n')
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()

await expect(
handlers['worktrees:remove'](null, { worktreeId, force: true })
).resolves.toEqual({ catalogVersion: anyCatalogVersion })

await expect(lstat(leftoverPath)).rejects.toMatchObject({ code: 'ENOENT' })
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(
worktreeId,
expect.objectContaining({ requirePhysicalStop: true })
)
expect(runHookMock).not.toHaveBeenCalled()
await rm(leftoverPath, { recursive: true, force: true })
await expect(handlers['worktrees:remove'](null, { worktreeId })).resolves.toEqual({
catalogVersion: anyCatalogVersion
})
expect(removeWorktreeMock).not.toHaveBeenCalled()
expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId)
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local')
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-1'
})
} finally {
await rm(parentDir, { recursive: true, force: true })
}
Expand Down
9 changes: 6 additions & 3 deletions src/main/ipc/worktrees/removal/execute-worktree-removal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { removeFolderWorkspace } from './remove-folder-workspace'
import { removeUnregisteredWorktree } from './remove-unregistered-worktree'
import { removeRegisteredLocalWorktree } from './remove-registered-local-worktree'
import { retryFailedLocalWorktreeRemoval } from './retry-failed-local-worktree-removal'
import { retryFailedRemovalUnlessRegistered } from '../../../worktree-removal-table'
import { retryFailedRemovalUnlessRegistered } from '../../../worktree-removal-leftover'

export async function executeWorktreeRemoval(
context: WorktreeIpcContext,
Expand Down Expand Up @@ -70,8 +70,11 @@ export async function executeWorktreeRemoval(
])
}
if (
retryFailedRemovalUnlessRegistered(args.worktreeId, worktreePath, registeredWorktrees, () =>
retryFailedLocalWorktreeRemoval(context, args, removalHostId)
await retryFailedRemovalUnlessRegistered(
args.worktreeId,
worktreePath,
registeredWorktrees,
() => retryFailedLocalWorktreeRemoval(context, args, removalHostId)
)
) {
return { removing: true }
Expand Down
34 changes: 17 additions & 17 deletions src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ export async function removeRegisteredLocalWorktree(
}
}

const cleanupPushTarget = () =>
cleanupUnusedWorktreePushTargetRemote(
repo.path,
args.worktreeId,
removedPushTarget,
context.store,
localWorktreeGitOptions
)
const finish = (checkoutDeleteSignal?: AbortSignal): Promise<RemoveWorktreeResult> =>
finishLocalWorktreeRemoval({
context,
Expand All @@ -146,7 +154,8 @@ export async function removeRegisteredLocalWorktree(
deleteBranch,
refreshedRegisteredWorktree,
removalGate,
checkoutDeleteSignal
checkoutDeleteSignal,
cleanupPushTarget
})
if (!removesInBackground(canonicalWorktreePath, localWorktreeGitOptions)) {
const result = await finish()
Expand All @@ -173,7 +182,8 @@ export async function removeRegisteredLocalWorktree(
})
return result
},
publish: () => runtime.publishWorktreeRemovalChange(repoId)
publish: () => runtime.publishWorktreeRemovalChange(repoId),
cleanupPushTargetRemote: cleanupPushTarget
})
return { removing: true }
}
Expand All @@ -191,7 +201,8 @@ async function finishLocalWorktreeRemoval({
deleteBranch,
refreshedRegisteredWorktree,
removalGate,
checkoutDeleteSignal
checkoutDeleteSignal,
cleanupPushTarget
}: {
context: WorktreeIpcContext
args: RemoveWorktreeArgs
Expand All @@ -206,6 +217,7 @@ async function finishLocalWorktreeRemoval({
refreshedRegisteredWorktree: GitWorktreeInfo
removalGate: { finish: (removed: boolean) => Promise<void> }
checkoutDeleteSignal?: AbortSignal
cleanupPushTarget: () => Promise<void>
}): Promise<RemoveWorktreeResult> {
const { store, runtime } = context
let removalResult: RemoveWorktreeResult | undefined
Expand Down Expand Up @@ -256,13 +268,7 @@ async function finishLocalWorktreeRemoval({
cwd: repo.path,
...localWorktreeGitOptions
}).catch(() => {})
await cleanupUnusedWorktreePushTargetRemote(
repo.path,
args.worktreeId,
removedPushTarget,
store,
localWorktreeGitOptions
)
await cleanupPushTarget()
runtime.clearOptimisticReconcileToken(args.worktreeId)
removeWorktreeMetadataAndTransientState(
store,
Expand Down Expand Up @@ -291,13 +297,7 @@ async function finishLocalWorktreeRemoval({
} finally {
await removalGate.finish(removalCompleted)
}
await cleanupUnusedWorktreePushTargetRemote(
repo.path,
args.worktreeId,
removedPushTarget,
store,
localWorktreeGitOptions
)
await cleanupPushTarget()
rememberPreservedBranchCleanupTarget(
args.worktreeId,
removalHostId,
Expand Down
Loading
Loading