From 622b2db7bec4a7de55da757e64180a2cd37cff3e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:34:11 -0700 Subject: [PATCH 01/31] fix(worktrees): a retried or resumed delete only removes the folder it started deleting A failed worktree delete stays listed for a retry, and an interrupted one is finished at the next start. Both later deletes force-deleted whatever was at the recorded path, proven only by "git does not list it and it has no .git", so an ordinary folder the user put at that path since was deleted with its files, with no click needed in the startup case (STA-9338). An accepted removal now records the checkout directory's identity (device, inode, creation time) in its durable record. A retry, a startup finish, and the in-slot check before the force-delete refuse any folder at the path that is not that directory. Startup no longer rebuilds a missing .git for a registered checkout unless the folder is the recorded one. Records without an identity (older builds, or no creation time) are only finished through Git's own checks and never force-deleted. --- .../remove-registered-local-worktree.ts | 4 + .../worktree-removal-failed-retry.spec.ts | 3 + ...time-failed-local-worktree-removal.test.ts | 92 ++++++++++++++-- ...interrupted-local-worktree-removal.test.ts | 85 ++++++++++++++- ...time-interrupted-local-worktree-removal.ts | 30 +++++- ...ntime-registered-local-worktree-removal.ts | 4 + ...orktree-background-removal-records.test.ts | 31 ++++++ src/main/worktree-background-removal.ts | 12 +-- src/main/worktree-checkout-identity.test.ts | 100 ++++++++++++++++++ src/main/worktree-checkout-identity.ts | 67 ++++++++++++ src/main/worktree-failed-removal.test.ts | 49 ++++++++- src/main/worktree-removal-leftover.ts | 56 ++++++++-- src/main/worktree-removal-listing.ts | 2 +- src/main/worktree-removal-records.ts | 17 +++ 14 files changed, 519 insertions(+), 33 deletions(-) create mode 100644 src/main/worktree-checkout-identity.test.ts create mode 100644 src/main/worktree-checkout-identity.ts diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index a75a2607c9e5..834d9f082973 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -44,6 +44,7 @@ import { removesInBackground, startBackgroundWorktreeRemoval } from '../../../worktree-background-removal' +import { readCheckoutDirectoryIdentity } from '../../../worktree-checkout-identity' export async function removeRegisteredLocalWorktree( context: WorktreeIpcContext, @@ -151,12 +152,15 @@ export async function removeRegisteredLocalWorktree( } // Why detached: every refusal above already ran, and Git's 20-35 s delete must finish even when the // request that asked for it goes away; other views read the host's `removing` marker meanwhile. + // Why: a later retry or resume may only delete this directory, never one made at the path since. + const checkoutIdentity = await readCheckoutDirectoryIdentity(refreshedRegisteredWorktree.path) void startBackgroundWorktreeRemoval({ removal: { worktreeId: args.worktreeId, repoId, repoPath: repo.path, worktree: refreshedRegisteredWorktree, + checkoutIdentity, deleteBranch, force: args.force ?? false }, diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 00cd9ad82226..1c0673748176 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -26,6 +26,7 @@ import { readWorktreeRemovalRecords, writeWorktreeRemovalRecords } from '../../worktree-removal-records' +import { readCheckoutDirectoryIdentity } from '../../worktree-checkout-identity' const FAILURE = "error: failed to delete 'node_modules/a/LICENSE': Operation not permitted" @@ -40,6 +41,7 @@ describe('runtime Delete on a failed delete’s leftover', () => { leftover = join(directory, 'feature') leftoverId = `${TEST_REPO_ID}::${leftover}` await mkdir(join(leftover, 'node_modules'), { recursive: true }) + const checkoutIdentity = await readCheckoutDirectoryIdentity(leftover) await writeWorktreeRemovalRecords(directory, () => [ { worktreeId: leftoverId, @@ -51,6 +53,7 @@ describe('runtime Delete on a failed delete’s leftover', () => { deleteBranch: true, force: true, requestedAt: 1, + checkoutIdentity, failure: { message: FAILURE, failedAt: 2 } } ]) diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 2aad80e10915..2ec8f51f53fc 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -38,6 +38,7 @@ import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from '../worktree-removal-records' +import { readCheckoutDirectoryIdentity } from '../worktree-checkout-identity' import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' vi.mock('../project-runtime-git-options', () => ({ @@ -120,7 +121,8 @@ async function finishAtStartup(purged: string[]): Promise { head: (await git(['rev-parse', 'feature'])).trim(), deleteBranch: true, force: true, - requestedAt: 1 + requestedAt: 1, + checkoutIdentity: await readCheckoutDirectoryIdentity(worktreePath) } await writeWorktreeRemovalRecords(recordsDir, () => [record]) await loadWorktreeRemovalRecords(recordsDir) @@ -144,6 +146,7 @@ async function failInSession(): Promise { repoId: repo.id, repoPath, worktree: { path: worktreePath, branch: 'refs/heads/feature', head: 'abc' }, + checkoutIdentity: await readCheckoutDirectoryIdentity(worktreePath), deleteBranch: true, force: true }, @@ -264,8 +267,8 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part it('never deletes a different checkout created at the path since', async () => { await failInSession() await setImmutable(false) - await rm(worktreePath, { recursive: true }) - await mkdir(worktreePath) + // In the leftover's own folder, so only its `.git` tells it apart. + await rm(join(worktreePath, '.git')) await git(['init', '-q'], worktreePath) await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') const purged: string[] = [] @@ -284,6 +287,52 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await listedRows()).toEqual([]) }) + it('never deletes an ordinary folder the user put at the path since', async () => { + await failInSession() + await setImmutable(false) + // No `.git`, as the leftover may have none: only the directory's identity tells them apart. + await rm(worktreePath, { recursive: true }) + await mkdir(worktreePath) + await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') + const purged: string[] = [] + + // Delete before any listing noticed: the retry refuses and lets the record go. + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => + interruptedLocalWorktreeRemovalJob(record, jobHost(purged)) + ) + await expect(retried).rejects.toThrow(/is not the one Orca started deleting/) + await _settlePendingWorktreeRemovalsForTests() + + expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + expect(await listedRows()).toEqual([]) + }) + + it('never deletes a failed delete’s folder that an older build recorded without an identity', async () => { + await failInSession() + await setImmutable(false) + const [{ checkoutIdentity: _identity, ...older }] = await readWorktreeRemovalRecords(recordsDir) + _resetPendingWorktreeRemovalsForTests() + await writeWorktreeRemovalRecords(recordsDir, () => [older]) + await loadWorktreeRemovalRecords(recordsDir) + const purged: string[] = [] + + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => + interruptedLocalWorktreeRemovalJob(record, jobHost(purged)) + ) + await expect(retried).rejects.toThrow(/is not the one Orca started deleting/) + await _settlePendingWorktreeRemovalsForTests() + + expect(existsSync(lockedFile)).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + expect(await listedRows()).toEqual([]) + }) + it('never deletes a worktree Git registers at the path since, even on the same branch', async () => { await failStartupFinish() await setImmutable(false) @@ -350,9 +399,8 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part (reason: unknown) => reason ) await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1)) - // While it waits, the leftover is replaced by a different checkout. - await rm(worktreePath, { recursive: true }) - await mkdir(worktreePath) + // While it waits, a different checkout is created in the leftover's own folder. + await rm(join(worktreePath, '.git')) await git(['init', '-q'], worktreePath) await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') releaseSlots() @@ -364,6 +412,38 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(removeHostTree).not.toHaveBeenCalled() }) + it('checks the folder is still the leftover inside the delete slot', async () => { + await failInSession() + await setImmutable(false) + let releaseSlots = (): void => {} + const held = new Promise((resolve) => { + releaseSlots = resolve + }) + const holders = [ + runUnderWorktreeDeleteLimit(() => held), + runUnderWorktreeDeleteLimit(() => held) + ] + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => + interruptedLocalWorktreeRemovalJob(record, jobHost([])) + ) + const settled = retried!.then( + () => undefined, + (reason: unknown) => reason + ) + await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1)) + // While it waits, the user replaces the leftover with an ordinary folder. + await rm(worktreePath, { recursive: true }) + await mkdir(worktreePath) + await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') + releaseSlots() + await Promise.all(holders) + + expect(String(await settled)).toMatch(/is not the one Orca started deleting/) + await _settlePendingWorktreeRemovalsForTests() + expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + }) + it('at startup, still deletes the recorded checkout when its missing .git cannot be restored', async () => { await setImmutable(false) // Git deleted `.git` first and the link cannot be written back, so Git cannot remove it. diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index 8fd770875878..507131b75248 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -2,7 +2,7 @@ // come from Git and disk, whatever point the earlier run reached. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, realpath, unlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -34,6 +34,10 @@ import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from '../worktree-removal-records' +import { + readCheckoutDirectoryIdentity, + type CheckoutDirectoryIdentity +} from '../worktree-checkout-identity' import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' vi.mock('../project-runtime-git-options', () => ({ @@ -57,6 +61,7 @@ let scratchDir = '' let recordsDir = '' let repoPath = '' let worktreePath = '' +let acceptedIdentity: CheckoutDirectoryIdentity | undefined let repo: Repo async function git(args: string[], cwd = repoPath): Promise { @@ -86,6 +91,8 @@ beforeEach(async () => { await git(['add', '-A']) await git(['commit', '-qm', 'seed']) await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature']) + // The directory as the interrupted removal accepted it, before any test's partial delete. + acceptedIdentity = await readCheckoutDirectoryIdentity(worktreePath) repo = { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '', addedAt: 0 } }) @@ -113,7 +120,8 @@ async function finishAfterRestart(options: { repoGone?: boolean; head?: string } head: options.head ?? (await git(['rev-parse', 'feature'])).trim(), deleteBranch: true, force: false, - requestedAt: 1 + requestedAt: 1, + checkoutIdentity: acceptedIdentity } await writeWorktreeRemovalRecords(recordsDir, () => [record]) await loadWorktreeRemovalRecords(recordsDir) @@ -204,6 +212,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { // Git before 2.48 has no relative-path worktrees. ctx.skip() } + acceptedIdentity = await readCheckoutDirectoryIdentity(worktreePath) await unlink(join(worktreePath, '.git')) await unlink(join(worktreePath, 'seed.txt')) @@ -261,6 +270,78 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(purged).toEqual([]) }) + it('leaves an ordinary folder put at the path after Git unregistered the checkout', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await git(['worktree', 'remove', '--force', worktreePath]) + // No `.git`, like the leftover Git leaves: only the directory's identity tells them apart. + await mkdir(worktreePath) + await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ + status: 'failed', + error: expect.stringMatching(/is not the one Orca started deleting/) + }) + expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + }) + + it('leaves an ordinary folder put at a still-registered checkout’s path', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(restoreMissingWorktreeGitFile).mockClear() + // Git's admin entry still names the path, so restoring `.git` would hand the folder to Git. + await rm(worktreePath, { recursive: true }) + await mkdir(worktreePath) + await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') + expect(await isRegistered(worktreePath)).toBe(true) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ + status: 'failed', + error: expect.stringMatching(/is not the one Orca started deleting/) + }) + expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) + expect(existsSync(join(worktreePath, '.git'))).toBe(false) + expect(restoreMissingWorktreeGitFile).not.toHaveBeenCalled() + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + }) + + it('leaves an unregistered folder an older build recorded without an identity', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + acceptedIdentity = undefined + // What Git leaves when it drops the registration first: no `.git`, files still there. + await unlink(join(worktreePath, '.git')) + await git(['worktree', 'prune']) + expect(await isRegistered(worktreePath)).toBe(false) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ + status: 'failed', + error: expect.stringMatching(/is not the one Orca started deleting/) + }) + expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + }) + + it('still lets Git finish a registered checkout an older build recorded without an identity', async () => { + acceptedIdentity = undefined + await unlink(join(worktreePath, 'seed.txt')) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(existsSync(worktreePath)).toBe(false) + expect(await isRegistered(worktreePath)).toBe(false) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([`repo-1::${worktreePath}`]) + }) + it('leaves the same branch checked out again at a new head', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const head = (await git(['rev-parse', 'feature'])).trim() diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 9f94feac0dc0..725d13ae4a87 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -21,8 +21,10 @@ import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { assertUnregisteredRemovalLeftover, differentCheckoutAtPathError, + differentFolderAtPathError, isUnregisteredRemovalLeftover } from '../worktree-removal-leftover' +import { matchCheckoutDirectory } from '../worktree-checkout-identity' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { WorktreeRemovalRecord } from '../worktree-removal-records' import { @@ -128,6 +130,8 @@ async function finishInterruptedLocalWorktreeRemoval( deleteBranch: record.deleteBranch, target: { id: record.worktreeId } } + // Why the repo's current path: the record's may predate a repo move. + const leftoverRecord = { ...record, repoPath: repo.path } const worktrees = await listWorktreesStrict(repo.path, localOptions) const registered = worktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath) @@ -146,13 +150,21 @@ async function finishInterruptedLocalWorktreeRemoval( ) } const gitLink = await readCheckoutGitLink(record.worktreePath) - // Why: the finish forces, so a checkout created at this path since the quit must not be taken. + const directory = await matchCheckoutDirectory(record.worktreePath, record.checkoutIdentity) + // Why: the finish forces and nobody asked for it this run, so it may only take the directory the + // removal accepted. A record without an identity (an older build's) is left to Git's own checks. + if ( + directory === 'different' || + (directory === 'unrecorded' && !(deletable && gitLink === 'present')) + ) { + throw differentFolderAtPathError(record.worktreePath) + } // At an unregistered path, only a `.git` naming the admin entry Git removed is this checkout's // own leftover (Git drops the registration even when its delete fails partway). if ( deletable ? !isRecordedCheckout(deletable, record) - : !(await isUnregisteredRemovalLeftover(repo.path, record.worktreePath)) + : !(await isUnregisteredRemovalLeftover(leftoverRecord)) ) { throw differentCheckoutAtPathError(record.worktreePath) } @@ -184,10 +196,18 @@ async function finishInterruptedLocalWorktreeRemoval( repo.path, record.worktreePath, record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null, - // Why only unregistered: a registered checkout here was just proven to be the recorded one. + // Why recheck in the slot: the wait can outlast large deletes, and the path may change meanwhile. deletable - ? async () => {} - : () => assertUnregisteredRemovalLeftover(repo.path, record.worktreePath, localOptions), + ? async () => { + const current = await matchCheckoutDirectory( + record.worktreePath, + record.checkoutIdentity + ) + if (current !== 'same' && current !== 'absent') { + throw differentFolderAtPathError(record.worktreePath) + } + } + : () => assertUnregisteredRemovalLeftover(leftoverRecord, localOptions), localOptions ) removed = true diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index a5e966b1471a..4ced725739c1 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -32,6 +32,7 @@ import { waitForPendingWorktreeRemoval } from '../worktree-background-removal' import { runSerializedWorktreeRemovalAcceptance } from '../worktree-removal-acceptance-queue' +import { readCheckoutDirectoryIdentity } from '../worktree-checkout-identity' /** Runs after the previous same-repo removal was accepted; see runSerializedWorktreeRemovalAcceptance. */ export function removeRuntimeRegisteredLocalWorktree( @@ -168,12 +169,15 @@ async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: { } // Why detached: every refusal above already ran, and Git's 20-35 s delete must finish even when the // request that asked for it times out; other views read the host's `removing` marker meanwhile. + // Why: a later retry or resume may only delete this directory, never one made at the path since. + const checkoutIdentity = await readCheckoutDirectoryIdentity(refreshed.path) void startBackgroundWorktreeRemoval({ removal: { worktreeId: args.target.id, repoId: repo.id, repoPath: repo.path, worktree: refreshed, + checkoutIdentity, deleteBranch: args.deleteBranch, force: args.force }, diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index 726240fefb1f..780b8c2ef82e 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -256,4 +256,35 @@ describe('durable worktree removal records', () => { await writeFile(worktreeRemovalRecordsFile(directory), '{not json') expect(await readWorktreeRemovalRecords(directory)).toEqual([]) }) + + it('keeps the accepted checkout identity, reading a malformed one as unrecorded', async () => { + const record = { + worktreeId: 'repo-1::/work/a', + repoId: 'repo-1', + repoPath: '/work/repo', + worktreePath: '/work/a', + branch: 'a', + head: 'abc', + deleteBranch: false, + force: true, + requestedAt: 5 + } + const checkoutIdentity = { dev: '16777232', ino: '18446744073709551615', birthtimeNs: '1' } + await writeFile( + worktreeRemovalRecordsFile(directory), + JSON.stringify({ + version: 1, + removals: [ + { ...record, checkoutIdentity }, + { ...record, worktreeId: 'repo-1::/work/b', checkoutIdentity: { dev: 1, ino: 2 } } + ] + }) + ) + + // The malformed one stays, so Git's own checks can still finish it. + expect(await readWorktreeRemovalRecords(directory)).toEqual([ + { ...record, checkoutIdentity }, + { ...record, worktreeId: 'repo-1::/work/b' } + ]) + }) }) diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 700ecd045cae..1d736d55ed5b 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -141,17 +141,18 @@ export function startBackgroundWorktreeRemoval( args: { removal: Pick< WorktreeRemovalRecord, - 'worktreeId' | 'repoId' | 'repoPath' | 'deleteBranch' | 'force' + 'worktreeId' | 'repoId' | 'repoPath' | 'deleteBranch' | 'force' | 'checkoutIdentity' > & { worktree: Pick } } & BackgroundWorktreeRemovalJob ): Promise { - const { worktree, ...accepted } = args.removal + const { worktree, checkoutIdentity, ...accepted } = args.removal const record: WorktreeRemovalRecord = { ...accepted, worktreePath: worktree.path, branch: normalizeLocalBranchRef(worktree.branch), head: worktree.head, - requestedAt: Date.now() + requestedAt: Date.now(), + ...(checkoutIdentity ? { checkoutIdentity } : {}) } const settlement = addPendingRemoval(record) runBackgroundWorktreeRemoval(record, args, persistWorktreeRemovalRecords()) @@ -301,10 +302,7 @@ async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promis return false } try { - return ( - !(await isCheckoutRegistered(record)) && - (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath)) - ) + return !(await isCheckoutRegistered(record)) && (await isUnregisteredRemovalLeftover(record)) } catch (error) { // Unknowable: the row stays however Git lists it, as before this record existed. console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) diff --git a/src/main/worktree-checkout-identity.test.ts b/src/main/worktree-checkout-identity.test.ts new file mode 100644 index 000000000000..8afd5e50c653 --- /dev/null +++ b/src/main/worktree-checkout-identity.test.ts @@ -0,0 +1,100 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + matchCheckoutDirectory, + readCheckoutDirectoryIdentity, + type CheckoutDirectoryIdentity +} from './worktree-checkout-identity' + +let directory = '' +let checkout = '' + +beforeEach(async () => { + directory = await realpath(await mkdtemp(join(tmpdir(), 'orca-checkout-identity-'))) + checkout = join(directory, 'feature') + await mkdir(checkout) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function acceptedIdentity(): Promise { + const identity = await readCheckoutDirectoryIdentity(checkout) + expect(identity).toBeDefined() + return identity ?? { dev: '', ino: '', birthtimeNs: '' } +} + +describe('matching the checkout directory a removal accepted', () => { + it('matches the same directory, whatever was deleted inside it', async () => { + await writeFile(join(checkout, 'seed.txt'), 'seed\n') + const identity = await acceptedIdentity() + await rm(join(checkout, 'seed.txt')) + + expect(await matchCheckoutDirectory(checkout, identity)).toBe('same') + }) + + it('does not match a directory removed and created again at the same path', async () => { + const identity = await acceptedIdentity() + await rm(checkout, { recursive: true }) + await mkdir(checkout) + await writeFile(join(checkout, 'notes.txt'), 'mine\n') + + expect(await matchCheckoutDirectory(checkout, identity)).toBe('different') + }) + + it('reports nothing at the path as absent, with or without an identity', async () => { + const identity = await acceptedIdentity() + await rm(checkout, { recursive: true }) + + expect(await matchCheckoutDirectory(checkout, identity)).toBe('absent') + expect(await matchCheckoutDirectory(checkout, undefined)).toBe('absent') + expect(await readCheckoutDirectoryIdentity(checkout)).toBeUndefined() + }) + + it('reports a removal recorded without an identity as unrecorded', async () => { + expect(await matchCheckoutDirectory(checkout, undefined)).toBe('unrecorded') + }) + + it('does not match a file put at the path', async () => { + const identity = await acceptedIdentity() + await rm(checkout, { recursive: true }) + await writeFile(checkout, 'mine\n') + + expect(await matchCheckoutDirectory(checkout, identity)).toBe('different') + expect(await readCheckoutDirectoryIdentity(checkout)).toBeUndefined() + }) + + it('does not match a symlink at the path, even one to the accepted directory', async () => { + const identity = await acceptedIdentity() + const link = join(directory, 'link') + // Junction on Windows: a directory symlink needs elevation there. + await symlink(checkout, link, process.platform === 'win32' ? 'junction' : 'dir') + + // Followed, the link reaches the accepted directory, but what sits at the path is the link. + expect(await matchCheckoutDirectory(link, identity)).toBe('different') + expect(await readCheckoutDirectoryIdentity(link)).toBeUndefined() + }) + + it('never matches an identity recorded without a creation time', async () => { + const identity = await acceptedIdentity() + + // Device and inode alone can name a different directory where inode numbers are reused. + expect(await matchCheckoutDirectory(checkout, { ...identity, birthtimeNs: '0' })).toBe( + 'different' + ) + }) + + it('does not match the same device and inode with a different creation time', async () => { + const identity = await acceptedIdentity() + + expect( + await matchCheckoutDirectory(checkout, { + ...identity, + birthtimeNs: (BigInt(identity.birthtimeNs) + 1n).toString() + }) + ).toBe('different') + }) +}) diff --git a/src/main/worktree-checkout-identity.ts b/src/main/worktree-checkout-identity.ts new file mode 100644 index 000000000000..6f3cb3257dc1 --- /dev/null +++ b/src/main/worktree-checkout-identity.ts @@ -0,0 +1,67 @@ +import { lstat } from 'node:fs/promises' +import { getErrorCode } from './git/worktree-operation-options' + +/** + * The directory a removal accepted, by filesystem identity. A delete Orca finishes later (a retry + * or a restart's resume) must only take that directory: a folder created at the same path since is + * the user's, however much it looks like the leftover. Decimal strings keep 64-bit ids exact. + */ +export type CheckoutDirectoryIdentity = { + dev: string + ino: string + /** Never `0`: without a creation time the directory is unidentifiable and nothing is recorded. */ + birthtimeNs: string +} + +/** `absent`: nothing at the path. `unrecorded`: no identity was recorded, so nothing is provable. */ +export type CheckoutDirectoryMatch = 'same' | 'different' | 'absent' | 'unrecorded' + +async function statDirectory(path: string): Promise { + try { + const stats = await lstat(path, { bigint: true }) + if (!stats.isDirectory()) { + return null + } + return { + dev: stats.dev.toString(), + ino: stats.ino.toString(), + birthtimeNs: stats.birthtimeNs.toString() + } + } catch (error) { + const code = getErrorCode(error) + // Unreadable proves nothing, so it never matches. + return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : null + } +} + +/** + * Identity of the checkout directory at `path`; undefined when there is no directory there or the + * filesystem keeps no creation time, since device and inode alone can name a newer directory. + */ +export async function readCheckoutDirectoryIdentity( + path: string +): Promise { + const current = await statDirectory(path) + return current && current !== 'absent' && current.birthtimeNs !== '0' ? current : undefined +} + +/** Whether `path` still holds the directory a removal accepted. */ +export async function matchCheckoutDirectory( + path: string, + accepted: CheckoutDirectoryIdentity | undefined +): Promise { + const current = await statDirectory(path) + if (current === 'absent') { + return 'absent' + } + if (!accepted) { + return 'unrecorded' + } + if (!current || current.dev !== accepted.dev || current.ino !== accepted.ino) { + return 'different' + } + // Why creation time too: Linux filesystems reuse a freed inode number for the next directory. + return accepted.birthtimeNs !== '0' && current.birthtimeNs === accepted.birthtimeNs + ? 'same' + : 'different' +} diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index f626e7bd3272..091789843f1e 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -2,6 +2,7 @@ // the error until Delete retries it, the checkout disappears, or its repo leaves Orca. Git is mocked // here so this runs on every platform; the real-Git version is in // runtime/runtime-failed-local-worktree-removal.test.ts. +import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -24,7 +25,8 @@ import { snapshotPendingWorktreeRemovals, withUnregisteredRemovalCheckouts } from './worktree-removal-listing' -import { readWorktreeRemovalRecords } from './worktree-removal-records' +import { readWorktreeRemovalRecords, writeWorktreeRemovalRecords } from './worktree-removal-records' +import { readCheckoutDirectoryIdentity } from './worktree-checkout-identity' import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-records-load' vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) @@ -60,13 +62,15 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -function startFailingRemoval(): Promise { +async function startFailingRemoval(): Promise { return startBackgroundWorktreeRemoval({ removal: { worktreeId, repoId: 'repo-1', repoPath: '/work/repo', worktree: { path: checkout, branch: 'refs/heads/feature', head: 'abc' }, + // As acceptance does: the directory the later retry may delete. + checkoutIdentity: await readCheckoutDirectoryIdentity(checkout), deleteBranch: true, force: true }, @@ -290,4 +294,45 @@ describe('a delete that fails after Git dropped the registration', () => { expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) ) }) + + it('ends, leaving the files, once the user puts an ordinary folder at the path', async () => { + await failRemoval() + // No `.git`, like the leftover: only the directory's identity tells them apart. + await rm(checkout, { recursive: true }) + await mkdir(checkout) + await writeFile(join(checkout, 'notes.txt'), 'mine\n') + + expect(await listRows()).toEqual([mainWorktree]) + expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() + await vi.waitFor(async () => + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + ) + expect(existsSync(join(checkout, 'notes.txt'))).toBe(true) + }) + + it('ends, leaving the files, for a failed delete an older build recorded without an identity', async () => { + await writeWorktreeRemovalRecords(join(directory, 'profile'), () => [ + { + worktreeId, + repoId: 'repo-1', + repoPath: '/work/repo', + worktreePath: checkout, + branch: 'feature', + head: 'abc', + deleteBranch: true, + force: true, + requestedAt: 1, + failure: { message: GIT_ERROR, failedAt: 2 } + } + ]) + await loadWorktreeRemovalRecords(join(directory, 'profile')) + + // Nothing proves the folder is still the one that delete accepted. + expect(await listRows()).toEqual([mainWorktree]) + expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() + await vi.waitFor(async () => + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + ) + expect(await readdir(checkout)).toEqual(['node_modules']) + }) }) diff --git a/src/main/worktree-removal-leftover.ts b/src/main/worktree-removal-leftover.ts index 3e17e415ee30..fc7dd8ba8498 100644 --- a/src/main/worktree-removal-leftover.ts +++ b/src/main/worktree-removal-leftover.ts @@ -9,22 +9,47 @@ import { canSafelyRemoveOrphanedWorktreeDirectory } from './worktree-removal-safety' import type { GitWorktreeExecOptions } from './git/worktree-operation-options' +import { matchCheckoutDirectory } from './worktree-checkout-identity' +import type { WorktreeRemovalRecord } from './worktree-removal-records' + +type RemovalLeftoverRecord = Pick< + WorktreeRemovalRecord, + 'repoPath' | 'worktreePath' | 'checkoutIdentity' +> /** * Whether a checkout path Git no longer registers still holds the removed checkout's own leftover: - * no `.git` (Git deleted it first), or a `.git` file naming the admin entry Git removed. Any other - * `.git` is a different checkout created at the path since. + * the very directory the removal accepted (or nothing at all), with no `.git` (Git deleted it first) + * or a `.git` file naming the admin entry Git removed. Anything else was put at the path since. */ export async function isUnregisteredRemovalLeftover( - repoPath: string, - worktreePath: string + record: RemovalLeftoverRecord ): Promise { + return (await unregisteredRemovalLeftoverVerdict(record)) === 'leftover' +} + +async function unregisteredRemovalLeftoverVerdict( + record: RemovalLeftoverRecord +): Promise<'leftover' | 'different-folder' | 'different-checkout'> { + const match = await matchCheckoutDirectory(record.worktreePath, record.checkoutIdentity) + if (match === 'absent') { + return 'leftover' + } + if (match !== 'same') { + return 'different-folder' + } try { - await lstat(join(worktreePath, '.git')) + await lstat(join(record.worktreePath, '.git')) } catch (error) { - return getErrorCode(error) === 'ENOENT' + return getErrorCode(error) === 'ENOENT' ? 'leftover' : 'different-checkout' } - return canSafelyRemoveOrphanedWorktreeDirectory(worktreePath, repoPath, CLIENT_REMOVAL_HOME) + return (await canSafelyRemoveOrphanedWorktreeDirectory( + record.worktreePath, + record.repoPath, + CLIENT_REMOVAL_HOME + )) + ? 'leftover' + : 'different-checkout' } /** The refusal when the path no longer holds the removed checkout's own leftover. */ @@ -34,6 +59,13 @@ export function differentCheckoutAtPathError(worktreePath: string): Error { ) } +/** The refusal when the folder at the path is not the one Orca started deleting. */ +export function differentFolderAtPathError(worktreePath: string): Error { + return new Error( + `The folder at ${worktreePath} is not the one Orca started deleting, so Orca left it in place.` + ) +} + /** Whether Git registers a checkout at the recorded path now. */ export async function isCheckoutRegistered(record: { repoPath: string @@ -49,16 +81,20 @@ export async function isCheckoutRegistered(record: { * registers at or inside it. Run right before the delete: the path can change while it waits. */ export async function assertUnregisteredRemovalLeftover( - repoPath: string, - worktreePath: string, + record: RemovalLeftoverRecord, options: GitWorktreeExecOptions = {} ): Promise { + const { repoPath, worktreePath } = record const worktrees = await listWorktreesStrict(repoPath, options) if (worktrees.some((worktree) => areWorktreePathsEqual(worktree.path, worktreePath))) { throw differentCheckoutAtPathError(worktreePath) } assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, worktrees) - if (!(await isUnregisteredRemovalLeftover(repoPath, worktreePath))) { + const verdict = await unregisteredRemovalLeftoverVerdict(record) + if (verdict === 'different-folder') { + throw differentFolderAtPathError(worktreePath) + } + if (verdict === 'different-checkout') { throw differentCheckoutAtPathError(worktreePath) } } diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts index fd4dd7bfce97..7bb6f4e06418 100644 --- a/src/main/worktree-removal-listing.ts +++ b/src/main/worktree-removal-listing.ts @@ -39,7 +39,7 @@ export async function withUnregisteredRemovalCheckouts( const failed = failedWorktreeRemovals.get(record.worktreeId) === record if ( (await worktreeCheckoutExists(record.worktreePath)) && - (!failed || (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath))) + (!failed || (await isUnregisteredRemovalLeftover(record))) ) { leftovers.push({ path: record.worktreePath, diff --git a/src/main/worktree-removal-records.ts b/src/main/worktree-removal-records.ts index b4e7dbdb7fa7..84e107fcd108 100644 --- a/src/main/worktree-removal-records.ts +++ b/src/main/worktree-removal-records.ts @@ -7,6 +7,7 @@ import { withSidecarSnapshotQueue, writeSidecarSnapshot } from './sidecar-snapshot-file' +import type { CheckoutDirectoryIdentity } from './worktree-checkout-identity' const RECORDS_FILE_NAME = 'orca-worktree-removals.json' const RECORDS_VERSION = 1 @@ -24,6 +25,8 @@ export type WorktreeRemovalRecord = { deleteBranch: boolean force: boolean requestedAt: number + /** The checkout directory when the removal was accepted; absent on records older builds wrote. */ + checkoutIdentity?: CheckoutDirectoryIdentity /** The delete failed after Git dropped the registration with the checkout still on disk. */ failure?: WorktreeRemovalFailure } @@ -51,8 +54,21 @@ function parseFailure(value: unknown): WorktreeRemovalFailure | null | undefined : null } +// Why unprovable rather than invalid: dropping the record would also drop a resume Git can finish. +function parseCheckoutIdentity(value: unknown): CheckoutDirectoryIdentity | undefined { + return isRecord(value) && + typeof value.dev === 'string' && + typeof value.ino === 'string' && + typeof value.birthtimeNs === 'string' + ? { dev: value.dev, ino: value.ino, birthtimeNs: value.birthtimeNs } + : undefined +} + function parseRecord(value: unknown): WorktreeRemovalRecord | null { const failure = isRecord(value) ? parseFailure(value.failure) : null + const checkoutIdentity = isRecord(value) + ? parseCheckoutIdentity(value.checkoutIdentity) + : undefined if ( failure === null || !isRecord(value) || @@ -78,6 +94,7 @@ function parseRecord(value: unknown): WorktreeRemovalRecord | null { deleteBranch: value.deleteBranch, force: value.force, requestedAt: value.requestedAt, + ...(checkoutIdentity ? { checkoutIdentity } : {}), ...(failure ? { failure } : {}) } } From c387ec95e7ad37eff9e9c0545c61ca8566457e50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:59:32 -0700 Subject: [PATCH 02/31] fix(worktrees): identify the accepted checkout by inode and a real creation time only The device number changes when an external volume is attached again, so a genuine leftover read as a different folder. Windows reports a missing creation time as a negative number rather than 0, which slipped past the guard. --- ...orktree-background-removal-records.test.ts | 7 ++-- src/main/worktree-checkout-identity.test.ts | 34 +++++++++++++++---- src/main/worktree-checkout-identity.ts | 27 +++++++++------ src/main/worktree-removal-records.ts | 7 ++-- 4 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index 780b8c2ef82e..58fdd5aa0b5a 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -269,14 +269,15 @@ describe('durable worktree removal records', () => { force: true, requestedAt: 5 } - const checkoutIdentity = { dev: '16777232', ino: '18446744073709551615', birthtimeNs: '1' } + const checkoutIdentity = { ino: '18446744073709551615', birthtimeNs: '1' } await writeFile( worktreeRemovalRecordsFile(directory), JSON.stringify({ version: 1, removals: [ - { ...record, checkoutIdentity }, - { ...record, worktreeId: 'repo-1::/work/b', checkoutIdentity: { dev: 1, ino: 2 } } + // A device number an earlier build of this change wrote is ignored. + { ...record, checkoutIdentity: { dev: '16777232', ...checkoutIdentity } }, + { ...record, worktreeId: 'repo-1::/work/b', checkoutIdentity: { ino: 2 } } ] }) ) diff --git a/src/main/worktree-checkout-identity.test.ts b/src/main/worktree-checkout-identity.test.ts index 8afd5e50c653..b93b2cbee074 100644 --- a/src/main/worktree-checkout-identity.test.ts +++ b/src/main/worktree-checkout-identity.test.ts @@ -1,13 +1,19 @@ -import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import type * as FsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { matchCheckoutDirectory, readCheckoutDirectoryIdentity, type CheckoutDirectoryIdentity } from './worktree-checkout-identity' +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, lstat: vi.fn(actual.lstat) } +}) + let directory = '' let checkout = '' @@ -24,7 +30,7 @@ afterEach(async () => { async function acceptedIdentity(): Promise { const identity = await readCheckoutDirectoryIdentity(checkout) expect(identity).toBeDefined() - return identity ?? { dev: '', ino: '', birthtimeNs: '' } + return identity ?? { ino: '', birthtimeNs: '' } } describe('matching the checkout directory a removal accepted', () => { @@ -81,10 +87,24 @@ describe('matching the checkout directory a removal accepted', () => { it('never matches an identity recorded without a creation time', async () => { const identity = await acceptedIdentity() - // Device and inode alone can name a different directory where inode numbers are reused. - expect(await matchCheckoutDirectory(checkout, { ...identity, birthtimeNs: '0' })).toBe( - 'different' - ) + // The inode alone can name a different directory where inode numbers are reused. Windows reports + // a missing creation time as a negative number. + for (const birthtimeNs of ['0', '-11644473600000000000', '']) { + expect(await matchCheckoutDirectory(checkout, { ...identity, birthtimeNs })).toBe('different') + } + }) + + it('records nothing for a directory the filesystem reports no creation time for', async () => { + for (const birthtimeNs of [0n, -11644473600000000000n]) { + const stats = await lstat(checkout, { bigint: true }) + vi.mocked(lstat).mockResolvedValueOnce(Object.assign(stats, { birthtimeNs })) + + expect(await readCheckoutDirectoryIdentity(checkout)).toBeUndefined() + } + }) + + it('records no device number, which changes when an external volume is attached again', async () => { + expect(Object.keys(await acceptedIdentity()).sort()).toEqual(['birthtimeNs', 'ino']) }) it('does not match the same device and inode with a different creation time', async () => { diff --git a/src/main/worktree-checkout-identity.ts b/src/main/worktree-checkout-identity.ts index 6f3cb3257dc1..ba5f4cb1bd70 100644 --- a/src/main/worktree-checkout-identity.ts +++ b/src/main/worktree-checkout-identity.ts @@ -7,9 +7,13 @@ import { getErrorCode } from './git/worktree-operation-options' * the user's, however much it looks like the leftover. Decimal strings keep 64-bit ids exact. */ export type CheckoutDirectoryIdentity = { - dev: string + // Why no device number: it changes when an external volume is attached again. ino: string - /** Never `0`: without a creation time the directory is unidentifiable and nothing is recorded. */ + /** + * Positive: a filesystem without creation times reports `0` (negative on Windows), and nothing is + * recorded. Linux without statx reports the change time instead, which a partial delete moves, so + * the leftover there reads as a different folder and is left in place. + */ birthtimeNs: string } @@ -22,11 +26,7 @@ async function statDirectory(path: string): Promise { const current = await statDirectory(path) - return current && current !== 'absent' && current.birthtimeNs !== '0' ? current : undefined + return current && current !== 'absent' && hasCreationTime(current) ? current : undefined } /** Whether `path` still holds the directory a removal accepted. */ @@ -57,11 +62,11 @@ export async function matchCheckoutDirectory( if (!accepted) { return 'unrecorded' } - if (!current || current.dev !== accepted.dev || current.ino !== accepted.ino) { + if (!current || current.ino !== accepted.ino) { return 'different' } // Why creation time too: Linux filesystems reuse a freed inode number for the next directory. - return accepted.birthtimeNs !== '0' && current.birthtimeNs === accepted.birthtimeNs + return hasCreationTime(accepted) && current.birthtimeNs === accepted.birthtimeNs ? 'same' : 'different' } diff --git a/src/main/worktree-removal-records.ts b/src/main/worktree-removal-records.ts index 84e107fcd108..c1d62f80b897 100644 --- a/src/main/worktree-removal-records.ts +++ b/src/main/worktree-removal-records.ts @@ -56,11 +56,8 @@ function parseFailure(value: unknown): WorktreeRemovalFailure | null | undefined // Why unprovable rather than invalid: dropping the record would also drop a resume Git can finish. function parseCheckoutIdentity(value: unknown): CheckoutDirectoryIdentity | undefined { - return isRecord(value) && - typeof value.dev === 'string' && - typeof value.ino === 'string' && - typeof value.birthtimeNs === 'string' - ? { dev: value.dev, ino: value.ino, birthtimeNs: value.birthtimeNs } + return isRecord(value) && typeof value.ino === 'string' && typeof value.birthtimeNs === 'string' + ? { ino: value.ino, birthtimeNs: value.birthtimeNs } : undefined } From 376f7c94df06dc2a5ad1e4079f97188da2e2a6eb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:00:29 -0700 Subject: [PATCH 03/31] refactor(worktrees): every background removal states the checkout identity it accepted A new acceptance site that forgot the identity used to compile and silently write records no retry or resume could ever finish. The resume tests also assert the identity was read, so their refusals can't pass as unrecorded. --- src/main/git/worktree-add-pending-removal.test.ts | 3 ++- src/main/ipc/worktrees-background-removal.test.ts | 3 ++- src/main/ipc/worktrees-failed-removal.test.ts | 3 ++- .../worktree-removal-archive-hook-gate.spec.ts | 3 ++- .../rpc/worktree-removal-marker-projection.test.ts | 3 ++- .../runtime-interrupted-local-worktree-removal.test.ts | 4 ++++ src/main/worktree-background-removal-records.test.ts | 3 ++- src/main/worktree-background-removal.test.ts | 3 ++- src/main/worktree-background-removal.ts | 9 +++++++-- 9 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/main/git/worktree-add-pending-removal.test.ts b/src/main/git/worktree-add-pending-removal.test.ts index fdb115979246..30185a2d2098 100644 --- a/src/main/git/worktree-add-pending-removal.test.ts +++ b/src/main/git/worktree-add-pending-removal.test.ts @@ -31,7 +31,8 @@ function startDeleting(): void { repoPath: '/repo', worktree: { path: '/repo-feature', branch: 'refs/heads/feature/test', head: 'abc' }, deleteBranch: true, - force: false + force: false, + checkoutIdentity: undefined }, run: () => new Promise(() => {}), publish: () => {} diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index ead826e7d629..d03c032864fb 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -267,7 +267,8 @@ describe('worktrees:remove in the background', () => { repoPath: '/workspace/repo', worktree: feature, deleteBranch: true, - force: false + force: false, + checkoutIdentity: undefined }, run: () => new Promise((resolve) => { diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index f1cfa13eb803..38252c1c198b 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -142,7 +142,8 @@ async function failAfterGitDroppedIt(): Promise { repoPath: '/workspace/repo', worktree: feature, deleteBranch: true, - force: false + force: false, + checkoutIdentity: undefined }, run: async () => { listWorktreesMock.mockResolvedValue([main]) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts index ee5d67745e64..69e59851aa30 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts @@ -255,7 +255,8 @@ describe('archive hook removal gate', () => { repoPath: TEST_REPO_PATH, worktree: { path: TEST_WORKTREE_PATH, branch: 'feature', head: 'feature' }, deleteBranch: true, - force: false + force: false, + checkoutIdentity: undefined }, run: () => desktopDelete.promise, publish: () => {} diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts index 12e4bf0f7a3e..88490f9cb6f3 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts @@ -55,7 +55,8 @@ describe('worktree listings while a checkout is being deleted', () => { repoPath: '/work/repo', worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, deleteBranch: true, - force: false + force: false, + checkoutIdentity: undefined }, run: () => new Promise(() => {}), publish: () => {} diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index 507131b75248..ebed5f6c944a 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -93,6 +93,8 @@ beforeEach(async () => { await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature']) // The directory as the interrupted removal accepted it, before any test's partial delete. acceptedIdentity = await readCheckoutDirectoryIdentity(worktreePath) + // Without one, the refusals below would pass as unrecorded records, not as different folders. + expect(acceptedIdentity).toBeDefined() repo = { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '', addedAt: 0 } }) @@ -213,6 +215,8 @@ describe('finishing an interrupted worktree removal after a restart', () => { ctx.skip() } acceptedIdentity = await readCheckoutDirectoryIdentity(worktreePath) + // Without one, the refusals below would pass as unrecorded records rather than as different folders. + expect(acceptedIdentity).toBeDefined() await unlink(join(worktreePath, '.git')) await unlink(join(worktreePath, 'seed.txt')) diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index 58fdd5aa0b5a..b9aa83bec25d 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -36,7 +36,8 @@ const removal = { repoPath: '/work/repo', worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, deleteBranch: true, - force: false + force: false, + checkoutIdentity: undefined } const isPending = (): boolean => waitForPendingWorktreeRemoval(removal.worktreeId) !== undefined let directory = '' diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts index 1517954f25cd..8f4dd906a031 100644 --- a/src/main/worktree-background-removal.test.ts +++ b/src/main/worktree-background-removal.test.ts @@ -20,7 +20,8 @@ const removal = { repoPath: '/work/repo', worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, deleteBranch: true, - force: false + force: false, + checkoutIdentity: undefined } const isPending = (hostId?: ExecutionHostId): boolean => waitForPendingWorktreeRemoval(removal.worktreeId, hostId) !== undefined diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 1d736d55ed5b..122e3873de5c 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -6,6 +6,7 @@ import { acquireWatcherRemovalGate, type WatcherRemovalGate } from './ipc/watche import { runWorktreeChangeInvalidators } from './ipc/worktree-change-invalidators' import { parseWslPath } from './wsl' import { readWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktree-removal-records' +import type { CheckoutDirectoryIdentity } from './worktree-checkout-identity' import { differentCheckoutAtPathError, isCheckoutRegistered, @@ -141,8 +142,12 @@ export function startBackgroundWorktreeRemoval( args: { removal: Pick< WorktreeRemovalRecord, - 'worktreeId' | 'repoId' | 'repoPath' | 'deleteBranch' | 'force' | 'checkoutIdentity' - > & { worktree: Pick } + 'worktreeId' | 'repoId' | 'repoPath' | 'deleteBranch' | 'force' + > & { + worktree: Pick + // Required even when undefined: a record without it is never retried or resumed. + checkoutIdentity: CheckoutDirectoryIdentity | undefined + } } & BackgroundWorktreeRemovalJob ): Promise { const { worktree, checkoutIdentity, ...accepted } = args.removal From c56fd44b7b7d6b9816dcd2079c5ca1480bfd17af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:07:46 -0700 Subject: [PATCH 04/31] fix(worktrees): a delete that leaves the user's folder in place also ends the workspace When Orca decided the folder at a failed or interrupted delete's path was not the one it started deleting, it dropped the removal record but kept the workspace's metadata. That metadata still says Orca created a checkout there, and with no record left, the next Delete of the same workspace (a runtime Delete from mobile, the CLI or a paired client, or a second desktop Delete on a row not yet refreshed) went to the cleanup that deletes an Orca-created folder Git no longer lists, and removed the user's folder after all. Now, when a removal ends without deleting while Git does not list the path (a listing ends a failed delete, or a retry, resume or first delete fails with no leftover to show), the host runs a finished delete's bookkeeping: metadata, terminal history, caches and change events. The folder and the branch are left alone, and nothing is ended while Git still lists the path. --- ...worktrees-orphan-directory-cleanup.test.ts | 89 ++++++++++++- .../worktree-removal-failed-retry.spec.ts | 118 +++++++++++++++++- src/main/runtime/orca-runtime.ts | 11 ++ src/main/worktree-background-removal.ts | 29 ++--- src/main/worktree-failed-removal.test.ts | 33 +++++ src/main/worktree-removal-listing.ts | 45 ++++++- src/main/worktree-removal-table.ts | 26 ++++ 7 files changed, 323 insertions(+), 28 deletions(-) diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index 4eb08162a8a6..1fba291e559e 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -1,7 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { + _settlePendingWorktreeRemovalsForTests, + loadWorktreeRemovalRecords +} from '../worktree-background-removal' +import { readCheckoutDirectoryIdentity } from '../worktree-checkout-identity' +import { writeWorktreeRemovalRecords } from '../worktree-removal-records' +import { setUnfinishedWorktreeRemovalHost } from '../worktree-removal-table' +import { removeWorktreeMetadataAndTransientState } from './worktrees/removal/worktree-removal-ownership' import { removeWorktreeMock, getEffectiveHooksMock, @@ -345,6 +354,84 @@ describe('registerWorktreeHandlers', () => { } }) + it('leaves a folder the user put at a failed delete’s path, on Delete and every Delete after', async () => { + const parentDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-ipc-replaced-leftover-'))) + const repoPath = join(parentDir, 'repo') + const leftoverPath = join(parentDir, 'leftover') + const worktreeId = `repo-1::${leftoverPath}` + await mkdir(join(leftoverPath, 'node_modules'), { recursive: true }) + await mkdir(join(parentDir, 'profile')) + const checkoutIdentity = await readCheckoutDirectoryIdentity(leftoverPath) + await writeWorktreeRemovalRecords(join(parentDir, 'profile'), () => [ + { + worktreeId, + repoId: 'repo-1', + repoPath, + worktreePath: leftoverPath, + branch: 'feature', + head: 'abc', + deleteBranch: true, + force: true, + requestedAt: 1, + checkoutIdentity, + failure: { message: 'Operation not permitted', failedAt: 2 } + } + ]) + await loadWorktreeRemovalRecords(join(parentDir, 'profile')) + const repo = { + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + } + store.getRepo.mockReturnValue(repo) + store.getRepos.mockReturnValue([repo]) + mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath) + // What Orca keeps for every workspace it created, which alone authorizes deleting the path. + let meta: Record | undefined = makeWorktreeMeta({ + orcaCreatedAt: Date.now(), + orcaCreationSource: 'runtime' + }) + store.getWorktreeMeta.mockImplementation((id: string) => (id === worktreeId ? meta : undefined)) + store.removeWorktreeMeta.mockImplementation((id: string) => { + if (id === worktreeId) { + meta = undefined + } + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'status') { + throw new Error('fatal: not a git repository') + } + return { stdout: '', stderr: '' } + }) + // Stands in for the runtime, which registers the bookkeeping that ends such a workspace. + setUnfinishedWorktreeRemovalHost((record) => + removeWorktreeMetadataAndTransientState(store as never, record.worktreeId, 'local') + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + // The user deletes the leftover by hand and makes a folder of their own at the path. + await rm(leftoverPath, { recursive: true }) + await mkdir(leftoverPath) + await writeFile(join(leftoverPath, 'notes.txt'), 'mine\n') + + try { + await expect(handlers['worktrees:remove'](null, { worktreeId, force: true })).rejects.toThrow( + /is not the one Orca started deleting/ + ) + await _settlePendingWorktreeRemovalsForTests() + // A second window, or this one before its row refreshes, still shows the workspace. + await handlers['worktrees:remove'](null, { worktreeId, force: true }).catch(() => {}) + + expect(existsSync(join(leftoverPath, 'notes.txt'))).toBe(true) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + } finally { + setUnfinishedWorktreeRemovalHost(null) + await rm(parentDir, { recursive: true, force: true }) + } + }) + it('rejects an Orca-created unregistered local directory with a git directory', async () => { const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-standalone-')) const repoPath = join(parentDir, 'repo') diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 1c0673748176..9689f88c1c67 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -5,6 +5,7 @@ import { existsSync } from 'node:fs' import { realpath } from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + gitRunner, join, listWorktreesStrict, mkdir, @@ -12,9 +13,14 @@ import { removeWorktree, rm, scanLocalRepoWorktreesForResolutionMock, - tmpdir + tmpdir, + writeFile } from '../orca-runtime-test-mocks.spec' -import { TEST_REPO_ID, TEST_REPO_PATH } from '../orca-runtime-test-fixtures.spec' +import { + TEST_REPO_ID, + TEST_REPO_PATH, + createStaleRuntimeWorktreeStore +} from '../orca-runtime-test-fixtures.spec' import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' import { _resetPendingWorktreeRemovalsForTests, @@ -209,3 +215,111 @@ describe('runtime listing straight after a delete fails partway', () => { }) }) }) + +describe('runtime Delete after the user replaced a failed delete’s leftover', () => { + let directory = '' + let leftover = '' + let leftoverId = '' + + beforeEach(async () => { + vi.clearAllMocks() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + directory = await realpath(await mkdtemp(join(tmpdir(), 'orca-runtime-replaced-leftover-'))) + leftover = join(directory, 'feature') + leftoverId = `${TEST_REPO_ID}::${leftover}` + await mkdir(join(leftover, 'node_modules'), { recursive: true }) + const checkoutIdentity = await readCheckoutDirectoryIdentity(leftover) + await writeWorktreeRemovalRecords(directory, () => [ + { + worktreeId: leftoverId, + repoId: TEST_REPO_ID, + repoPath: TEST_REPO_PATH, + worktreePath: leftover, + branch: 'feature', + head: 'abc', + deleteBranch: true, + force: true, + requestedAt: 1, + checkoutIdentity, + failure: { message: FAILURE, failedAt: 2 } + } + ]) + await loadWorktreeRemovalRecords(directory) + vi.mocked(listWorktreesStrict).mockResolvedValue([]) + vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'status') { + throw new Error('fatal: not a git repository') + } + return { stdout: '', stderr: '' } + }) + // The user deletes the leftover by hand and makes a folder of their own at the path. + await rm(leftover, { recursive: true, force: true }) + await mkdir(leftover) + await writeFile(join(leftover, 'notes.txt'), 'mine\n') + }) + + afterEach(async () => { + await _settlePendingWorktreeRemovalsForTests() + _resetPendingWorktreeRemovalsForTests() + vi.restoreAllMocks() + await rm(directory, { recursive: true, force: true }) + }) + + // What Orca keeps for every workspace it created, which alone once authorized deleting the path. + function runtimeWithCreationMetadata() { + const stale = createStaleRuntimeWorktreeStore(leftoverId, { + orcaCreatedAt: Date.now(), + orcaCreationSource: 'runtime' + }) + return { runtime: createWorktreeRemovalRuntime(stale.runtimeStore), ...stale } + } + + async function deleteById(runtime: ReturnType) { + await runtime + .removeManagedWorktree(`id:${leftoverId}`, { force: true, waitForBackgroundRemoval: true }) + .catch(() => {}) + await _settlePendingWorktreeRemovalsForTests() + } + + it('leaves the folder on Delete, and on every Delete after, ending the workspace', async () => { + const { runtime, runtimeStore } = runtimeWithCreationMetadata() + + await deleteById(runtime) + await deleteById(runtime) + + expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) + expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() + await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) + }) + + it('leaves the folder on a Delete after another client’s listing ended the failed delete', async () => { + const { runtime, runtimeStore } = runtimeWithCreationMetadata() + + const listed = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + expect(listed.worktrees.some((row) => row.id === leftoverId)).toBe(false) + await deleteById(runtime) + + expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) + expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() + await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) + }) + + it('keeps the workspace when Git registers a checkout at the path again after the scan', async () => { + vi.mocked(listWorktreesStrict).mockResolvedValue([ + { + path: leftover, + head: 'def', + branch: 'refs/heads/other', + isBare: false, + isMainWorktree: false + } + ]) + scanLocalRepoWorktreesForResolutionMock.mockResolvedValue({ ok: true, worktrees: [] }) + const { runtime, removeWorktreeMeta } = runtimeWithCreationMetadata() + + await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + + await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) + expect(removeWorktreeMeta).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 287285c8ea70..ceac921c7245 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import { registerWorktreeChangeInvalidator } from '../ipc/worktree-change-invalidators' import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation' +import { setUnfinishedWorktreeRemovalHost } from '../worktree-removal-table' class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter { constructor(...args: ConstructorParameters) { @@ -12,6 +13,16 @@ class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter { // module registers the generation bump at load; a headless host never loads it. registerDetectedWorktreeScanInvalidation() registerWorktreeChangeInvalidator((repoId) => this.invalidateWorktreeCatalog(repoId)) + // Why: a listing can end a failed delete with no job to run this host's bookkeeping; it runs a + // finished delete's, leaving the folder and branch. + setUnfinishedWorktreeRemovalHost((record) => { + const host = this.store && this.localRemovalJobHost(this.store) + if (host) { + host.purge(record) + host.onRemoved(record) + host.publish(record.repoId) + } + }) } } type OrcaRuntimeServiceExport = RuntimeCommandSurfaceHost diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 122e3873de5c..899c20593e6d 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -7,12 +7,10 @@ import { runWorktreeChangeInvalidators } from './ipc/worktree-change-invalidator import { parseWslPath } from './wsl' import { readWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktree-removal-records' import type { CheckoutDirectoryIdentity } from './worktree-checkout-identity' +import { differentCheckoutAtPathError, isCheckoutRegistered } from './worktree-removal-leftover' +import { checkoutLeftByFailedRemoval } from './worktree-removal-listing' import { - differentCheckoutAtPathError, - isCheckoutRegistered, - isUnregisteredRemovalLeftover -} from './worktree-removal-leftover' -import { + endUnfinishedWorktreeRemoval, failedWorktreeRemovals, finishedWorktreeRemovals, pendingWorktreeRemovals, @@ -251,6 +249,7 @@ async function settleBackgroundWorktreeRemoval( await waitForRecordWrite(record, recorded) let settle: (settlement: RemovalSettlement) => void let failure: WorktreeRemovalRecord['failure'] + let unregistered = false try { if (stopSignal.aborted) { return @@ -267,12 +266,14 @@ async function settleBackgroundWorktreeRemoval( settle = (settlement) => settlement.reject(error) // Why: Git drops the registration even when it fails to delete the checkout, and Orca lists // workspaces from Git, so without the record the leftover would vanish with no way to retry. - if (await isCheckoutLeftUnregistered(record)) { + const left = await checkoutLeftByFailedRemoval(record) + if (left === 'leftover') { failure = { message: error instanceof Error ? error.message : String(error), failedAt: Date.now() } } + unregistered = left === 'unregistered' } finally { // A resumed job that ended before taking its own gate still holds the fence loading gave it. releaseStartupRemovalFence(record.worktreeId) @@ -287,6 +288,9 @@ async function settleBackgroundWorktreeRemoval( failedWorktreeRemovals.set(record.worktreeId, { ...record, failure }) // Git's catalog changed under a failed delete; cached scans still list the checkout. runWorktreeChangeInvalidators(record.repoId) + } else if (unregistered) { + // Before the reply, so a Delete sent after it finds no workspace left at the path. + endUnfinishedWorktreeRemoval(record) } } // Why this run's own settlement: desktop IPC and runtime RPC coalesce separately, so a concurrent @@ -302,19 +306,6 @@ async function settleBackgroundWorktreeRemoval( } } -async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promise { - if (!(await worktreeCheckoutExists(record.worktreePath))) { - return false - } - try { - return !(await isCheckoutRegistered(record)) && (await isUnregisteredRemovalLeftover(record)) - } catch (error) { - // Unknowable: the row stays however Git lists it, as before this record existed. - console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) - return false - } -} - // Why: the record should be on disk before Git deletes, so a quit resumes the delete, but a disk or // file pool stall must not hold the user's delete; past this, a crash only loses the resume. const RECORD_WRITE_WAIT_MS = 2_000 diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 091789843f1e..887b4182b885 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -27,6 +27,7 @@ import { } from './worktree-removal-listing' import { readWorktreeRemovalRecords, writeWorktreeRemovalRecords } from './worktree-removal-records' import { readCheckoutDirectoryIdentity } from './worktree-checkout-identity' +import { setUnfinishedWorktreeRemovalHost } from './worktree-removal-table' import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-records-load' vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) @@ -57,6 +58,7 @@ beforeEach(async () => { }) afterEach(async () => { + setUnfinishedWorktreeRemovalHost(null) _resetPendingWorktreeRemovalsForTests() vi.restoreAllMocks() await rm(directory, { recursive: true, force: true }) @@ -131,6 +133,8 @@ describe('a delete that fails after Git dropped the registration', () => { }) it('clears the record as before when Git still registers the checkout', async () => { + const endWorkspace = vi.fn() + setUnfinishedWorktreeRemovalHost(endWorkspace) vi.mocked(listWorktreesStrict).mockResolvedValue([ mainWorktree, { ...leftoverRow(), removalError: undefined } @@ -138,6 +142,8 @@ describe('a delete that fails after Git dropped the registration', () => { await failRemoval() expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + // The workspace is still Git's, row and all. + expect(endWorkspace).not.toHaveBeenCalled() }) it('clears the record as before when the checkout is gone', async () => { @@ -191,7 +197,29 @@ describe('a delete that fails after Git dropped the registration', () => { expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() }) + it('ends the workspace when a retry finds a folder the user put at the path', async () => { + const endWorkspace = vi.fn() + setUnfinishedWorktreeRemovalHost(endWorkspace) + await failRemoval() + await rm(checkout, { recursive: true }) + await mkdir(checkout) + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', () => ({ + run: async () => { + throw new Error('not the folder Orca started deleting') + }, + publish: () => {} + })) + + await expect(retried).rejects.toThrow('not the folder') + // Already by the reply, so a Delete sent after it finds nothing to delete at the path. + expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) + await _settlePendingWorktreeRemovalsForTests() + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + }) + it('keeps the row with the new error when the retry fails the same way', async () => { + const endWorkspace = vi.fn() + setUnfinishedWorktreeRemovalHost(endWorkspace) await failRemoval() const retried = retryFailedWorktreeRemoval(worktreeId, undefined, () => ({ run: async () => { @@ -206,6 +234,7 @@ describe('a delete that fails after Git dropped the registration', () => { mainWorktree, { ...leftoverRow(), removalError: 'still not permitted' } ]) + expect(endWorkspace).not.toHaveBeenCalled() }) it('does not run the recorded removal once Git registers a checkout at the path again', async () => { @@ -296,6 +325,8 @@ describe('a delete that fails after Git dropped the registration', () => { }) it('ends, leaving the files, once the user puts an ordinary folder at the path', async () => { + const endWorkspace = vi.fn() + setUnfinishedWorktreeRemovalHost(endWorkspace) await failRemoval() // No `.git`, like the leftover: only the directory's identity tells them apart. await rm(checkout, { recursive: true }) @@ -303,6 +334,8 @@ describe('a delete that fails after Git dropped the registration', () => { await writeFile(join(checkout, 'notes.txt'), 'mine\n') expect(await listRows()).toEqual([mainWorktree]) + // With its workspace, whose creation metadata would let a later Delete take the folder. + expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts index 7bb6f4e06418..31ce34a5ca9a 100644 --- a/src/main/worktree-removal-listing.ts +++ b/src/main/worktree-removal-listing.ts @@ -1,9 +1,10 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' import type { GitWorktreeInfo } from '../shared/worktree/types' import { areWorktreePathsEqual } from './git/worktree-path-comparison' -import { isUnregisteredRemovalLeftover } from './worktree-removal-leftover' +import { isCheckoutRegistered, isUnregisteredRemovalLeftover } from './worktree-removal-leftover' import type { WorktreeRemovalRecord } from './worktree-removal-records' import { + endUnfinishedWorktreeRemoval, failedWorktreeRemovals, finishedWorktreeRemovals, pendingWorktreeRemovals, @@ -19,7 +20,8 @@ const NO_PENDING_REMOVALS: PendingWorktreeRemovals = new Map() /** * Git's rows for a local repo plus one for each removal this host still owns whose checkout Git no * longer lists but is still on disk: a failed delete (carrying its error) or one still finishing. - * A failed delete ends here once its checkout is gone or a different checkout took the path. + * A failed delete ends here, with its workspace, once its checkout is gone or a different folder + * took the path. */ export async function withUnregisteredRemovalCheckouts( repoId: string, @@ -34,7 +36,7 @@ export async function withUnregisteredRemovalCheckouts( return gitWorktrees } const leftovers: GitWorktreeInfo[] = [] - let droppedFailure = false + const ended: WorktreeRemovalRecord[] = [] for (const record of unlisted) { const failed = failedWorktreeRemovals.get(record.worktreeId) === record if ( @@ -49,17 +51,48 @@ export async function withUnregisteredRemovalCheckouts( isMainWorktree: false, ...(record.failure ? { removalError: record.failure.message } : {}) }) - } else if (failed) { + } else if (failedWorktreeRemovals.get(record.worktreeId) === record) { + // Read again, not `failed`: a Delete may have retried it while this listing read the disk. failedWorktreeRemovals.delete(record.worktreeId) - droppedFailure = true + ended.push(record) } } - if (droppedFailure) { + if (ended.length > 0) { void persistWorktreeRemovalRecords() } + for (const record of ended) { + // Why ask Git again: the rows may be a cached scan, and a checkout Git registers at the path + // since is a new workspace. Unknowable keeps it. + if (!(await isCheckoutRegistered(record).catch(() => true))) { + endUnfinishedWorktreeRemoval(record) + } + } return leftovers.length === 0 ? gitWorktrees : [...gitWorktrees, ...leftovers] } +/** + * What a failed delete left at its path: `leftover`, the accepted checkout Git no longer + * registers; `unregistered`, nothing Git registers and no leftover (a different folder, or none); + * `registered`, a checkout Git still registers, or no answer. + */ +export async function checkoutLeftByFailedRemoval( + record: WorktreeRemovalRecord +): Promise<'leftover' | 'unregistered' | 'registered'> { + try { + if (await isCheckoutRegistered(record)) { + return 'registered' + } + return (await worktreeCheckoutExists(record.worktreePath)) && + (await isUnregisteredRemovalLeftover(record)) + ? 'leftover' + : 'unregistered' + } catch (error) { + // Unknowable: the row stays however Git lists it, as before this record existed. + console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) + return 'registered' + } +} + /** Taken before a listing reads Git; pass it to projectPendingWorktreeRemovals with the rows. */ export function snapshotPendingWorktreeRemovals(): PendingWorktreeRemovals { return pendingWorktreeRemovals.size === 0 ? NO_PENDING_REMOVALS : new Map(pendingWorktreeRemovals) diff --git a/src/main/worktree-removal-table.ts b/src/main/worktree-removal-table.ts index 8c901bc9e37b..187bc7260c42 100644 --- a/src/main/worktree-removal-table.ts +++ b/src/main/worktree-removal-table.ts @@ -14,11 +14,37 @@ export const failedWorktreeRemovals = new Map() // Why weak: a listing that read Git before a delete finished holds the record until it replies. export const finishedWorktreeRemovals = new WeakSet() let recordsDirectory: string | null = null +let endWorkspaceOnHost: ((record: WorktreeRemovalRecord) => void) | null = null export function setWorktreeRemovalRecordsDirectory(directory: string | null): void { recordsDirectory = directory } +/** The host's bookkeeping for a finished delete, minus the disk: metadata, history, events. */ +export function setUnfinishedWorktreeRemovalHost( + endWorkspace: ((record: WorktreeRemovalRecord) => void) | null +): void { + endWorkspaceOnHost = endWorkspace +} + +/** + * Ends the workspace of a removal that stopped without deleting its checkout while Git does not + * list the path: the folder there is not the one it accepted, or is gone. Leaves folder and branch. + * Why: the workspace's creation metadata would otherwise let a later Delete of the same id remove + * whatever folder is at the path now. + */ +export function endUnfinishedWorktreeRemoval(record: WorktreeRemovalRecord): void { + // A newer removal of the workspace owns it now. + if (pendingWorktreeRemovals.has(record.worktreeId)) { + return + } + try { + endWorkspaceOnHost?.(record) + } catch (error) { + console.warn(`[worktrees] could not end the workspace at ${record.worktreePath}`, error) + } +} + export function persistWorktreeRemovalRecords(): Promise { if (!recordsDirectory) { return Promise.resolve() From d35ad998675d370597ac1714846240fc13d93ee6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:08:20 -0700 Subject: [PATCH 05/31] test(worktrees): cover the in-slot folder check when Git cannot remove a registered checkout Reverting that check left every test green: only the unregistered branch's check had a test where the folder changes during the delete-slot wait. --- ...time-failed-local-worktree-removal.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 2ec8f51f53fc..dd69b1666171 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -444,6 +444,32 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(removeHostTree).not.toHaveBeenCalled() }) + it('at startup, checks the folder is still the recorded checkout inside the delete slot when Git cannot remove it', async () => { + await setImmutable(false) + await rm(join(worktreePath, '.git')) + vi.mocked(restoreMissingWorktreeGitFile).mockResolvedValueOnce(false) + let releaseSlots = (): void => {} + const held = new Promise((resolve) => { + releaseSlots = resolve + }) + const holders = [ + runUnderWorktreeDeleteLimit(() => held), + runUnderWorktreeDeleteLimit(() => held) + ] + const finished = finishAtStartup([]) + await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1)) + // While it waits, the user replaces the checkout with an ordinary folder. + await rm(worktreePath, { recursive: true }) + await mkdir(worktreePath) + await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') + releaseSlots() + await Promise.all(holders) + + expect(String(await finished)).toMatch(/is not the one Orca started deleting/) + expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + }) + it('at startup, still deletes the recorded checkout when its missing .git cannot be restored', async () => { await setImmutable(false) // Git deleted `.git` first and the link cannot be written back, so Git cannot remove it. From c9df7ab0df31cbf9393af0174f4238f12e2c0403 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:11:39 -0700 Subject: [PATCH 06/31] fix(worktrees): a resumed or retried Git delete checks the folder inside the delete slot The check that the folder at the path is still the one Orca accepted ran before the watcher gate and the delete-slot wait, which can last minutes behind other large deletes. A folder restored there meanwhile with a copy of the checkout's .git file passes Git's own validation, so `git worktree remove --force` deleted it. The resume and retry now repeat the check inside the slot, right before Git deletes, as the non-Git branch already did; the refusal skips the Windows recovery that could otherwise finish the delete itself. A normal first delete is unchanged. --- src/main/git/worktree-operation-options.ts | 2 ++ src/main/git/worktree-removal.ts | 2 ++ ...time-failed-local-worktree-removal.test.ts | 31 ++++++++++++++++++- ...time-interrupted-local-worktree-removal.ts | 26 +++++++++------- ...ntime-registered-local-worktree-removal.ts | 23 ++++++++++++-- 5 files changed, 70 insertions(+), 14 deletions(-) diff --git a/src/main/git/worktree-operation-options.ts b/src/main/git/worktree-operation-options.ts index 82c37a1a679d..ce8135e1ebfa 100644 --- a/src/main/git/worktree-operation-options.ts +++ b/src/main/git/worktree-operation-options.ts @@ -44,6 +44,8 @@ export type RemoveWorktreeOptions = GitWorktreeExecOptions & { knownRemovedWorktree?: Pick /** Stops only `git worktree remove`; a killed branch cleanup could strand a ref lock. */ checkoutDeleteSignal?: AbortSignal + /** Runs in the delete slot right before Git deletes; a throw refuses the delete. */ + assertCheckoutBeforeDelete?: () => Promise } // Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); ample for an ordinary large checkout, but not one behind a slow content filter (#12696). diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 2f7eb8c38e00..9d35fad85dee 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -80,6 +80,8 @@ async function performRemoveWorktree( } args.push(worktreePath) await runUnderWorktreeDeleteLimit(async () => { + // Why in the slot: the wait can outlast two large deletes, and the path may change meanwhile. + await options.assertCheckoutBeforeDelete?.() await gitExecFileAsync(args, execOptions) await removeCheckoutLeftByGit(worktreePath, options) }) diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index dd69b1666171..06baddd99a2b 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -4,7 +4,7 @@ // the file's owner; worktree-failed-removal.test.ts covers the same rules with Git mocked. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -444,6 +444,35 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(removeHostTree).not.toHaveBeenCalled() }) + it('at startup, checks the folder is still the recorded checkout inside the delete slot before Git deletes it', async () => { + await setImmutable(false) + const gitFile = await readFile(join(worktreePath, '.git'), 'utf8') + let releaseSlots = (): void => {} + const held = new Promise((resolve) => { + releaseSlots = resolve + }) + const holders = [ + runUnderWorktreeDeleteLimit(() => held), + runUnderWorktreeDeleteLimit(() => held) + ] + const finished = finishAtStartup([]) + await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1)) + // While it waits, the user restores the folder from a backup, `.git` file and all, which Git + // accepts as the checkout. + await rm(worktreePath, { recursive: true }) + await mkdir(worktreePath) + await writeFile(join(worktreePath, '.git'), gitFile) + await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') + releaseSlots() + await Promise.all(holders) + + // The refusal itself, not a failed Git delete a recovery could finish by deleting the path. + expect(String(await finished)).toMatch(/^Error: The folder at .* is not the one Orca started/) + expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) + expect(await isRegistered(worktreePath)).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + }) + it('at startup, checks the folder is still the recorded checkout inside the delete slot when Git cannot remove it', async () => { await setImmutable(false) await rm(join(worktreePath, '.git')) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 725d13ae4a87..a6760b8635a8 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -175,6 +175,14 @@ async function finishInterruptedLocalWorktreeRemoval( assertWorktreeUnlockedForRemoval(deletable) gitCanRemove = await restoreMissingWorktreeGitFile(repo.path, deletable.path, localOptions) } + // Why recheck in the slot: the wait can outlast large deletes, and the path may change meanwhile. + // An older build's record (`unrecorded`) reaches only Git's delete, which validates the checkout. + const assertAcceptedDirectory = async (): Promise => { + const current = await matchCheckoutDirectory(record.worktreePath, record.checkoutIdentity) + if (current === 'different' || (current === 'unrecorded' && !gitCanRemove)) { + throw differentFolderAtPathError(record.worktreePath) + } + } const gate = await args.acquireWatcherRemoval(record.worktreePath) if (args.stopPtys) { try { @@ -185,7 +193,12 @@ async function finishInterruptedLocalWorktreeRemoval( } } if (deletable && gitCanRemove) { - return finishRuntimeLocalWorktreeRemoval(finishArgs, deletable, gate, args.stopSignal) + return finishRuntimeLocalWorktreeRemoval( + { ...finishArgs, assertCheckoutBeforeDelete: assertAcceptedDirectory }, + deletable, + gate, + args.stopSignal + ) } // Unregistered, or no admin entry claims the checkout so Git cannot validate it: the leftover is // deleted in this process as a last resort, then pruned. @@ -196,17 +209,8 @@ async function finishInterruptedLocalWorktreeRemoval( repo.path, record.worktreePath, record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null, - // Why recheck in the slot: the wait can outlast large deletes, and the path may change meanwhile. deletable - ? async () => { - const current = await matchCheckoutDirectory( - record.worktreePath, - record.checkoutIdentity - ) - if (current !== 'same' && current !== 'absent') { - throw differentFolderAtPathError(record.worktreePath) - } - } + ? assertAcceptedDirectory : () => assertUnregisteredRemovalLeftover(leftoverRecord, localOptions), localOptions ) diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index 4ced725739c1..694aade71657 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -202,7 +202,11 @@ export type RuntimeLocalWorktreeRemovalFinishArgs = Pick< | 'closeWatchers' | 'preserveBranchHead' | 'finishRemoval' -> & { target: { id: string } } +> & { + target: { id: string } + /** For a finish nobody asked for this run: refuses in the delete slot if the checkout changed. */ + assertCheckoutBeforeDelete?: () => Promise +} /** Git's delete and everything after it; the refusals and teardown before it already ran. */ export async function finishRuntimeLocalWorktreeRemoval( @@ -215,6 +219,8 @@ export async function finishRuntimeLocalWorktreeRemoval( const canonicalPath = refreshed.path let removalResult: RemoveWorktreeResult | undefined let completed = false + const { assertCheckoutBeforeDelete } = args + let refusal: unknown try { try { removalResult = args.preserveBranchHead( @@ -222,11 +228,24 @@ export async function finishRuntimeLocalWorktreeRemoval( ...(!args.deleteBranch ? { deleteBranch: args.deleteBranch } : {}), knownRemovedWorktree: refreshed, ...localOptions, - ...(checkoutDeleteSignal ? { checkoutDeleteSignal } : {}) + ...(checkoutDeleteSignal ? { checkoutDeleteSignal } : {}), + ...(assertCheckoutBeforeDelete + ? { + assertCheckoutBeforeDelete: () => + assertCheckoutBeforeDelete().catch((error: unknown) => { + refusal = error + throw error + }) + } + : {}) }), refreshed.head ) } catch (error) { + // Why: Git deleted nothing, so no recovery below may delete the path in its place. + if (error === refusal) { + throw error + } const recovered = await recoverLocalWindowsWorktreeRemoval({ error, force: args.force, From e196709b2e2c20fcdcc09d3f65b01e3fb9ec8309 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:12:22 -0700 Subject: [PATCH 07/31] test(worktrees): assert the second desktop Delete refuses the user's folder The handler result is untyped, so the previous catch-all did not typecheck; the assertion now names the refusal the Delete gets once the workspace has ended. --- src/main/ipc/worktrees-orphan-directory-cleanup.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index 1fba291e559e..964de28361b9 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -422,7 +422,9 @@ describe('registerWorktreeHandlers', () => { ) await _settlePendingWorktreeRemovalsForTests() // A second window, or this one before its row refreshes, still shows the workspace. - await handlers['worktrees:remove'](null, { worktreeId, force: true }).catch(() => {}) + await expect(handlers['worktrees:remove'](null, { worktreeId, force: true })).rejects.toThrow( + `Refusing to delete unregistered worktree path: ${leftoverPath}` + ) expect(existsSync(join(leftoverPath, 'notes.txt'))).toBe(true) expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') From f523be0d1af12e2d188ac613be23c404fd9823ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:34:22 -0700 Subject: [PATCH 08/31] test(worktrees): the desktop Delete test's stand-in host ends the workspace without a cast --- src/main/ipc/worktrees-orphan-directory-cleanup.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index 964de28361b9..4b700d7dc172 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -10,7 +10,6 @@ import { import { readCheckoutDirectoryIdentity } from '../worktree-checkout-identity' import { writeWorktreeRemovalRecords } from '../worktree-removal-records' import { setUnfinishedWorktreeRemovalHost } from '../worktree-removal-table' -import { removeWorktreeMetadataAndTransientState } from './worktrees/removal/worktree-removal-ownership' import { removeWorktreeMock, getEffectiveHooksMock, @@ -408,7 +407,7 @@ describe('registerWorktreeHandlers', () => { }) // Stands in for the runtime, which registers the bookkeeping that ends such a workspace. setUnfinishedWorktreeRemovalHost((record) => - removeWorktreeMetadataAndTransientState(store as never, record.worktreeId, 'local') + store.removeWorktreeMeta(record.worktreeId, 'local') ) vi.spyOn(console, 'warn').mockImplementation(() => {}) // The user deletes the leftover by hand and makes a folder of their own at the path. From 67da9f334cd787f31972584747a44d018c7fbbc3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:36:53 -0700 Subject: [PATCH 09/31] fix(worktrees): a listing asks Git before dropping a failed delete, and ends its workspace in the same step --- .../worktree-removal-failed-retry.spec.ts | 34 +++++++++++++ src/main/worktree-failed-removal.test.ts | 48 +++++++++++++++++++ src/main/worktree-removal-listing.ts | 29 ++++++----- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 9689f88c1c67..525e31420fa5 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -304,6 +304,40 @@ describe('runtime Delete after the user replaced a failed delete’s leftover', await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) }) + it('leaves the folder on a Delete after a listing could not ask Git about the failed delete', async () => { + vi.mocked(listWorktreesStrict).mockRejectedValueOnce(new Error('git worktree list timed out')) + const { runtime, runtimeStore } = runtimeWithCreationMetadata() + + await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + // Kept for the next listing to decide, rather than dropped with the workspace left behind. + expect(await readWorktreeRemovalRecords(directory)).toHaveLength(1) + await deleteById(runtime) + + expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) + expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() + }) + + it('leaves the folder on a Delete made while a listing asks Git about the failed delete', async () => { + let answerGit!: () => void + let askedGit!: () => void + const listingAskedGit = new Promise((resolve) => (askedGit = resolve)) + vi.mocked(listWorktreesStrict).mockImplementationOnce(() => { + askedGit() + return new Promise((resolve) => (answerGit = () => resolve([]))) + }) + const { runtime, runtimeStore } = runtimeWithCreationMetadata() + + const listing = runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + await listingAskedGit + await deleteById(runtime) + const notesAfterDelete = existsSync(join(leftover, 'notes.txt')) + answerGit() + await listing + + expect(notesAfterDelete).toBe(true) + expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() + }) + it('keeps the workspace when Git registers a checkout at the path again after the scan', async () => { vi.mocked(listWorktreesStrict).mockResolvedValue([ { diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 887b4182b885..b4d86a9b4fa9 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -343,6 +343,54 @@ describe('a delete that fails after Git dropped the registration', () => { expect(existsSync(join(checkout, 'notes.txt'))).toBe(true) }) + it('keeps the failed delete for the next listing while Git cannot say whether it registers the path', async () => { + const endWorkspace = vi.fn() + setUnfinishedWorktreeRemovalHost(endWorkspace) + await failRemoval() + await rm(checkout, { recursive: true }) + await mkdir(checkout) + vi.mocked(listWorktreesStrict).mockRejectedValueOnce(new Error('git worktree list timed out')) + + expect(await listRows()).toEqual([mainWorktree]) + expect(endWorkspace).not.toHaveBeenCalled() + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) + + expect(await listRows()).toEqual([mainWorktree]) + expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) + await vi.waitFor(async () => + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + ) + }) + + it('still lets a Delete retry it while a listing asks Git whether to end it', async () => { + const endWorkspace = vi.fn() + setUnfinishedWorktreeRemovalHost(endWorkspace) + await failRemoval() + await rm(checkout, { recursive: true }) + await mkdir(checkout) + let answerGit!: () => void + let askedGit!: () => void + const listingAskedGit = new Promise((resolve) => (askedGit = resolve)) + vi.mocked(listWorktreesStrict).mockImplementationOnce(() => { + askedGit() + return new Promise((resolve) => (answerGit = () => resolve([mainWorktree]))) + }) + + const listing = listRows() + await listingAskedGit + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', () => ({ + run: async () => { + throw new Error('not the folder Orca started deleting') + }, + publish: () => {} + })) + await expect(retried).rejects.toThrow('not the folder') + answerGit() + + expect(await listing).toEqual([mainWorktree]) + expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) + }) + it('ends, leaving the files, for a failed delete an older build recorded without an identity', async () => { await writeWorktreeRemovalRecords(join(directory, 'profile'), () => [ { diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts index 31ce34a5ca9a..721b726f9ce7 100644 --- a/src/main/worktree-removal-listing.ts +++ b/src/main/worktree-removal-listing.ts @@ -36,7 +36,7 @@ export async function withUnregisteredRemovalCheckouts( return gitWorktrees } const leftovers: GitWorktreeInfo[] = [] - const ended: WorktreeRemovalRecord[] = [] + const notLeftovers: WorktreeRemovalRecord[] = [] for (const record of unlisted) { const failed = failedWorktreeRemovals.get(record.worktreeId) === record if ( @@ -51,22 +51,29 @@ export async function withUnregisteredRemovalCheckouts( isMainWorktree: false, ...(record.failure ? { removalError: record.failure.message } : {}) }) - } else if (failedWorktreeRemovals.get(record.worktreeId) === record) { - // Read again, not `failed`: a Delete may have retried it while this listing read the disk. - failedWorktreeRemovals.delete(record.worktreeId) - ended.push(record) + } else if (failed) { + notLeftovers.push(record) } } - if (ended.length > 0) { - void persistWorktreeRemovalRecords() - } - for (const record of ended) { + let dropped = false + for (const record of notLeftovers) { // Why ask Git again: the rows may be a cached scan, and a checkout Git registers at the path - // since is a new workspace. Unknowable keeps it. - if (!(await isCheckoutRegistered(record).catch(() => true))) { + // since is a new workspace. Unknowable keeps the record for the next listing to decide. + const registered = await isCheckoutRegistered(record).catch(() => undefined) + // Read again: a Delete may have retried it meanwhile. No await from here to the end, or a + // Delete in between would find neither the record nor the end of its workspace. + if (registered === undefined || failedWorktreeRemovals.get(record.worktreeId) !== record) { + continue + } + failedWorktreeRemovals.delete(record.worktreeId) + dropped = true + if (!registered) { endUnfinishedWorktreeRemoval(record) } } + if (dropped) { + void persistWorktreeRemovalRecords() + } return leftovers.length === 0 ? gitWorktrees : [...gitWorktrees, ...leftovers] } From 646d79e9bef87efa8b6d5bcd5c2f1b85862e57e0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:38:24 -0700 Subject: [PATCH 10/31] fix(worktrees): a failed delete Git cannot be asked about stays failed for the next listing to decide --- .../worktree-removal-failed-retry.spec.ts | 22 +++++++++++++++ src/main/worktree-background-removal.ts | 3 ++- src/main/worktree-failed-removal.test.ts | 27 +++++++++++++++++++ src/main/worktree-removal-listing.ts | 7 +++-- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 525e31420fa5..250b27b1eb2a 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -338,6 +338,28 @@ describe('runtime Delete after the user replaced a failed delete’s leftover', expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() }) + it('leaves the folder on a Delete after a restart’s refused delete could not ask Git what is left', async () => { + // The delete was still running when Orca quit; the user replaced the folder before the restart. + const [{ failure: _failure, ...interrupted }] = await readWorktreeRemovalRecords(directory) + _resetPendingWorktreeRemovalsForTests() + await writeWorktreeRemovalRecords(directory, () => [interrupted]) + await loadWorktreeRemovalRecords(directory) + vi.mocked(listWorktreesStrict) + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new Error('git worktree list timed out')) + const { runtime, runtimeStore } = runtimeWithCreationMetadata() + + runtime.finishInterruptedWorktreeRemovals() + await _settlePendingWorktreeRemovalsForTests() + expect(await readWorktreeRemovalRecords(directory)).toMatchObject([ + { worktreeId: leftoverId, failure: { message: expect.stringMatching(/is not the one Orca/) } } + ]) + await deleteById(runtime) + + expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) + expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() + }) + it('keeps the workspace when Git registers a checkout at the path again after the scan', async () => { vi.mocked(listWorktreesStrict).mockResolvedValue([ { diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 899c20593e6d..20861ba685ff 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -267,7 +267,8 @@ async function settleBackgroundWorktreeRemoval( // Why: Git drops the registration even when it fails to delete the checkout, and Orca lists // workspaces from Git, so without the record the leftover would vanish with no way to retry. const left = await checkoutLeftByFailedRemoval(record) - if (left === 'leftover') { + // Unknown is kept as failed too, so a listing decides again what the path holds. + if (left === 'leftover' || left === 'unknown') { failure = { message: error instanceof Error ? error.message : String(error), failedAt: Date.now() diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index b4d86a9b4fa9..9c9140bba620 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -217,6 +217,33 @@ describe('a delete that fails after Git dropped the registration', () => { expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) }) + it('keeps a refused retry for the next listing while Git cannot say what is at the path', async () => { + const endWorkspace = vi.fn() + setUnfinishedWorktreeRemovalHost(endWorkspace) + await failRemoval() + await rm(checkout, { recursive: true }) + await mkdir(checkout) + vi.mocked(listWorktreesStrict) + .mockResolvedValueOnce([mainWorktree]) + .mockRejectedValueOnce(new Error('git worktree list timed out')) + const retried = retryFailedWorktreeRemoval(worktreeId, 'local', () => ({ + run: async () => { + throw new Error('not the folder Orca started deleting') + }, + publish: () => {} + })) + + await expect(retried).rejects.toThrow('not the folder') + await _settlePendingWorktreeRemovalsForTests() + expect(endWorkspace).not.toHaveBeenCalled() + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toMatchObject([ + { worktreeId, failure: { message: 'not the folder Orca started deleting' } } + ]) + + expect(await listRows()).toEqual([mainWorktree]) + expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) + }) + it('keeps the row with the new error when the retry fails the same way', async () => { const endWorkspace = vi.fn() setUnfinishedWorktreeRemovalHost(endWorkspace) diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts index 721b726f9ce7..6f6d2c909655 100644 --- a/src/main/worktree-removal-listing.ts +++ b/src/main/worktree-removal-listing.ts @@ -80,11 +80,11 @@ export async function withUnregisteredRemovalCheckouts( /** * What a failed delete left at its path: `leftover`, the accepted checkout Git no longer * registers; `unregistered`, nothing Git registers and no leftover (a different folder, or none); - * `registered`, a checkout Git still registers, or no answer. + * `registered`, a checkout Git still registers; `unknown`, no answer. */ export async function checkoutLeftByFailedRemoval( record: WorktreeRemovalRecord -): Promise<'leftover' | 'unregistered' | 'registered'> { +): Promise<'leftover' | 'unregistered' | 'registered' | 'unknown'> { try { if (await isCheckoutRegistered(record)) { return 'registered' @@ -94,9 +94,8 @@ export async function checkoutLeftByFailedRemoval( ? 'leftover' : 'unregistered' } catch (error) { - // Unknowable: the row stays however Git lists it, as before this record existed. console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) - return 'registered' + return 'unknown' } } From 91d7652292ddedf26d003dac5c7e4e83512a3059 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:39:36 -0700 Subject: [PATCH 11/31] test(worktrees): the failed-delete record test states that Git still registers the checkout --- .../worktree-background-removal-records.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index b9aa83bec25d..52b17d9edae9 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -15,6 +15,7 @@ import { projectPendingWorktreeRemovals, snapshotPendingWorktreeRemovals } from './worktree-removal-listing' +import type * as GitWorktree from './git/worktree' import type * as WorktreeRemovalRecords from './worktree-removal-records' import { readWorktreeRemovalRecords, @@ -22,6 +23,20 @@ import { writeWorktreeRemovalRecords } from './worktree-removal-records' +// Git still registers the checkout: a delete that fails leaves it Git's, row and all. +vi.mock('./git/worktree', async (importOriginal) => ({ + ...(await importOriginal()), + listWorktreesStrict: vi.fn(async () => [ + { + path: '/work/feature', + head: 'abc', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) +})) + vi.mock('./worktree-removal-records', async (importOriginal) => { const actual = await importOriginal() return { From 36b38715b102d745eb88ba1b000220c7e9812642 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:40:11 -0700 Subject: [PATCH 12/31] fix(worktrees): a restart keeps a failed delete whose checkout is gone, so the first listing ends its workspace --- .../worktree-removal-failed-retry.spec.ts | 14 ++++++++++++++ src/main/worktree-background-removal.ts | 6 +++--- src/main/worktree-failed-removal.test.ts | 11 +++++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 250b27b1eb2a..c9350524d992 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -360,6 +360,20 @@ describe('runtime Delete after the user replaced a failed delete’s leftover', expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() }) + it('leaves a folder made at the path after a restart found the leftover gone', async () => { + await rm(leftover, { recursive: true, force: true }) + _resetPendingWorktreeRemovalsForTests() + await loadWorktreeRemovalRecords(directory) + await mkdir(leftover) + await writeFile(join(leftover, 'notes.txt'), 'mine\n') + const { runtime, runtimeStore } = runtimeWithCreationMetadata() + + await deleteById(runtime) + + expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) + expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() + }) + it('keeps the workspace when Git registers a checkout at the path again after the scan', async () => { vi.mocked(listWorktreesStrict).mockResolvedValue([ { diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 20861ba685ff..1c345e6687aa 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -15,8 +15,7 @@ import { finishedWorktreeRemovals, pendingWorktreeRemovals, persistWorktreeRemovalRecords, - setWorktreeRemovalRecordsDirectory, - worktreeCheckoutExists + setWorktreeRemovalRecordsDirectory } from './worktree-removal-table' export type BackgroundWorktreeRemovalJob = { @@ -52,7 +51,8 @@ export async function loadWorktreeRemovalRecords( for (const record of await readWorktreeRemovalRecords(directory)) { if (record.failure) { // Why the repo: only its listing shows the row, so nothing else could end a removed repo's. - if (hasRepo(record.repoId) && (await worktreeCheckoutExists(record.worktreePath))) { + // Kept with its checkout gone too: that listing also ends the workspace, which loading can't. + if (hasRepo(record.repoId)) { failedWorktreeRemovals.set(record.worktreeId, record) } else { droppedFailure = true diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 9c9140bba620..7d358e436b67 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -299,14 +299,21 @@ describe('a delete that fails after Git dropped the registration', () => { ) }) - it('ends at startup once the checkout is deleted outside Orca', async () => { + it('ends with its workspace at the first listing after a restart, once the checkout is deleted outside Orca', async () => { + const endWorkspace = vi.fn() + setUnfinishedWorktreeRemovalHost(endWorkspace) await failRemoval() _resetPendingWorktreeRemovalsForTests() await rm(checkout, { recursive: true }) await loadWorktreeRemovalRecords(join(directory, 'profile')) + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + expect(await listRows()).toEqual([mainWorktree]) + expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) + await vi.waitFor(async () => + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + ) }) it('ends at startup once its repo is removed from Orca, leaving the files', async () => { From c3a349bc1eeb29d6a010a5029832893b18827596 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:43:47 -0700 Subject: [PATCH 13/31] fix(worktrees): a folder Orca cannot read keeps its failed delete and is never deleted --- src/main/ipc/worktrees-failed-removal.test.ts | 3 +- ...interrupted-local-worktree-removal.test.ts | 32 ++++++++++++++++++- ...time-interrupted-local-worktree-removal.ts | 9 +++++- src/main/worktree-checkout-identity.test.ts | 9 ++++++ src/main/worktree-checkout-identity.ts | 19 +++++++---- src/main/worktree-failed-removal.test.ts | 21 +++++++++++- src/main/worktree-removal-leftover.ts | 20 ++++++++++-- src/main/worktree-removal-listing.ts | 24 ++++++++++---- 8 files changed, 117 insertions(+), 20 deletions(-) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index 38252c1c198b..39c32dd043cb 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -115,7 +115,8 @@ vi.mock('../worktree-removal-table', async (importOriginal) => ({ })) vi.mock('../worktree-removal-leftover', async (importOriginal) => ({ ...(await importOriginal()), - isUnregisteredRemovalLeftover: vi.fn(async () => true) + isUnregisteredRemovalLeftover: vi.fn(async () => true), + unregisteredRemovalLeftoverVerdict: vi.fn(async () => 'leftover') })) const featureId = 'repo-1::/workspace/feature-wt' diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index ebed5f6c944a..be30f854e974 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -2,7 +2,8 @@ // come from Git and disk, whatever point the earlier run reached. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, realpath, rm, unlink, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, realpath, rm, unlink, writeFile } from 'node:fs/promises' +import type * as FsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -40,6 +41,10 @@ import { } from '../worktree-checkout-identity' import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, lstat: vi.fn(actual.lstat) } +}) vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: () => ({}) })) @@ -314,6 +319,31 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(purged).toEqual([]) }) + it('leaves a checkout it cannot read, since nothing proves it is the one it accepted', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await unlink(join(worktreePath, 'seed.txt')) + const { lstat: readLstat } = await vi.importActual('node:fs/promises') + const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + vi.mocked(lstat).mockImplementation((path, options) => + path === worktreePath && options?.bigint ? Promise.reject(denied) : readLstat(path, options) + ) + + try { + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ + status: 'failed', + error: expect.stringMatching(/could not read the folder/) + }) + expect(existsSync(join(worktreePath, '.git'))).toBe(true) + expect(await isRegistered(worktreePath)).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + } finally { + vi.mocked(lstat).mockReset() + } + }) + it('leaves an unregistered folder an older build recorded without an identity', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) acceptedIdentity = undefined diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index a6760b8635a8..ea9be2543a0d 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -22,7 +22,8 @@ import { assertUnregisteredRemovalLeftover, differentCheckoutAtPathError, differentFolderAtPathError, - isUnregisteredRemovalLeftover + isUnregisteredRemovalLeftover, + unreadableFolderAtPathError } from '../worktree-removal-leftover' import { matchCheckoutDirectory } from '../worktree-checkout-identity' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' @@ -151,6 +152,9 @@ async function finishInterruptedLocalWorktreeRemoval( } const gitLink = await readCheckoutGitLink(record.worktreePath) const directory = await matchCheckoutDirectory(record.worktreePath, record.checkoutIdentity) + if (directory === 'unreadable') { + throw unreadableFolderAtPathError(record.worktreePath) + } // Why: the finish forces and nobody asked for it this run, so it may only take the directory the // removal accepted. A record without an identity (an older build's) is left to Git's own checks. if ( @@ -179,6 +183,9 @@ async function finishInterruptedLocalWorktreeRemoval( // An older build's record (`unrecorded`) reaches only Git's delete, which validates the checkout. const assertAcceptedDirectory = async (): Promise => { const current = await matchCheckoutDirectory(record.worktreePath, record.checkoutIdentity) + if (current === 'unreadable') { + throw unreadableFolderAtPathError(record.worktreePath) + } if (current === 'different' || (current === 'unrecorded' && !gitCanRemove)) { throw differentFolderAtPathError(record.worktreePath) } diff --git a/src/main/worktree-checkout-identity.test.ts b/src/main/worktree-checkout-identity.test.ts index b93b2cbee074..cf5078796924 100644 --- a/src/main/worktree-checkout-identity.test.ts +++ b/src/main/worktree-checkout-identity.test.ts @@ -60,6 +60,15 @@ describe('matching the checkout directory a removal accepted', () => { expect(await readCheckoutDirectoryIdentity(checkout)).toBeUndefined() }) + it('reports a path it cannot read as unreadable, neither the same nor a different folder', async () => { + const identity = await acceptedIdentity() + const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + vi.mocked(lstat).mockRejectedValueOnce(denied).mockRejectedValueOnce(denied) + + expect(await matchCheckoutDirectory(checkout, identity)).toBe('unreadable') + expect(await readCheckoutDirectoryIdentity(checkout)).toBeUndefined() + }) + it('reports a removal recorded without an identity as unrecorded', async () => { expect(await matchCheckoutDirectory(checkout, undefined)).toBe('unrecorded') }) diff --git a/src/main/worktree-checkout-identity.ts b/src/main/worktree-checkout-identity.ts index ba5f4cb1bd70..f7bcf3bae399 100644 --- a/src/main/worktree-checkout-identity.ts +++ b/src/main/worktree-checkout-identity.ts @@ -17,10 +17,15 @@ export type CheckoutDirectoryIdentity = { birthtimeNs: string } -/** `absent`: nothing at the path. `unrecorded`: no identity was recorded, so nothing is provable. */ -export type CheckoutDirectoryMatch = 'same' | 'different' | 'absent' | 'unrecorded' +/** + * `absent`: nothing at the path. `unrecorded`: no identity was recorded, so nothing is provable. + * `unreadable`: the path could not be read just now, so it is neither the same nor different. + */ +export type CheckoutDirectoryMatch = 'same' | 'different' | 'absent' | 'unrecorded' | 'unreadable' -async function statDirectory(path: string): Promise { +async function statDirectory( + path: string +): Promise { try { const stats = await lstat(path, { bigint: true }) if (!stats.isDirectory()) { @@ -29,8 +34,7 @@ async function statDirectory(path: string): Promise { const current = await statDirectory(path) - return current && current !== 'absent' && hasCreationTime(current) ? current : undefined + return typeof current === 'object' && current && hasCreationTime(current) ? current : undefined } /** Whether `path` still holds the directory a removal accepted. */ @@ -62,6 +66,9 @@ export async function matchCheckoutDirectory( if (!accepted) { return 'unrecorded' } + if (current === 'unreadable') { + return 'unreadable' + } if (!current || current.ino !== accepted.ino) { return 'different' } diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 7d358e436b67..13b944d9c5be 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -3,7 +3,8 @@ // here so this runs on every platform; the real-Git version is in // runtime/runtime-failed-local-worktree-removal.test.ts. import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import type * as FsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -31,6 +32,10 @@ import { setUnfinishedWorktreeRemovalHost } from './worktree-removal-table' import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-records-load' vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, lstat: vi.fn(actual.lstat) } +}) const GIT_ERROR = "error: failed to delete 'node_modules/a/LICENSE': Operation not permitted" let directory = '' @@ -244,6 +249,20 @@ describe('a delete that fails after Git dropped the registration', () => { expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) }) + it('keeps the row, and its workspace, while the leftover cannot be read', async () => { + const endWorkspace = vi.fn() + setUnfinishedWorktreeRemovalHost(endWorkspace) + await failRemoval() + // A read error, like a briefly unreachable share: once to find it, once to identify it. + const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + vi.mocked(lstat).mockRejectedValueOnce(denied).mockRejectedValueOnce(denied) + + expect(await listRows()).toEqual([mainWorktree, leftoverRow()]) + expect(endWorkspace).not.toHaveBeenCalled() + expect(await listRows()).toEqual([mainWorktree, leftoverRow()]) + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) + }) + it('keeps the row with the new error when the retry fails the same way', async () => { const endWorkspace = vi.fn() setUnfinishedWorktreeRemovalHost(endWorkspace) diff --git a/src/main/worktree-removal-leftover.ts b/src/main/worktree-removal-leftover.ts index fc7dd8ba8498..ceaf98c7c50a 100644 --- a/src/main/worktree-removal-leftover.ts +++ b/src/main/worktree-removal-leftover.ts @@ -28,20 +28,24 @@ export async function isUnregisteredRemovalLeftover( return (await unregisteredRemovalLeftoverVerdict(record)) === 'leftover' } -async function unregisteredRemovalLeftoverVerdict( +/** As isUnregisteredRemovalLeftover, telling a path that could not be read from a different one. */ +export async function unregisteredRemovalLeftoverVerdict( record: RemovalLeftoverRecord -): Promise<'leftover' | 'different-folder' | 'different-checkout'> { +): Promise<'leftover' | 'unreadable' | 'different-folder' | 'different-checkout'> { const match = await matchCheckoutDirectory(record.worktreePath, record.checkoutIdentity) if (match === 'absent') { return 'leftover' } + if (match === 'unreadable') { + return 'unreadable' + } if (match !== 'same') { return 'different-folder' } try { await lstat(join(record.worktreePath, '.git')) } catch (error) { - return getErrorCode(error) === 'ENOENT' ? 'leftover' : 'different-checkout' + return getErrorCode(error) === 'ENOENT' ? 'leftover' : 'unreadable' } return (await canSafelyRemoveOrphanedWorktreeDirectory( record.worktreePath, @@ -66,6 +70,13 @@ export function differentFolderAtPathError(worktreePath: string): Error { ) } +/** The refusal when the path could not be read, so nothing proves it holds the accepted folder. */ +export function unreadableFolderAtPathError(worktreePath: string): Error { + return new Error( + `Orca could not read the folder at ${worktreePath}, so Orca left it in place. Delete it again once it can be read.` + ) +} + /** Whether Git registers a checkout at the recorded path now. */ export async function isCheckoutRegistered(record: { repoPath: string @@ -91,6 +102,9 @@ export async function assertUnregisteredRemovalLeftover( } assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, worktrees) const verdict = await unregisteredRemovalLeftoverVerdict(record) + if (verdict === 'unreadable') { + throw unreadableFolderAtPathError(worktreePath) + } if (verdict === 'different-folder') { throw differentFolderAtPathError(worktreePath) } diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts index 6f6d2c909655..c082d4d17ddf 100644 --- a/src/main/worktree-removal-listing.ts +++ b/src/main/worktree-removal-listing.ts @@ -1,7 +1,10 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' import type { GitWorktreeInfo } from '../shared/worktree/types' import { areWorktreePathsEqual } from './git/worktree-path-comparison' -import { isCheckoutRegistered, isUnregisteredRemovalLeftover } from './worktree-removal-leftover' +import { + isCheckoutRegistered, + unregisteredRemovalLeftoverVerdict +} from './worktree-removal-leftover' import type { WorktreeRemovalRecord } from './worktree-removal-records' import { endUnfinishedWorktreeRemoval, @@ -41,7 +44,7 @@ export async function withUnregisteredRemovalCheckouts( const failed = failedWorktreeRemovals.get(record.worktreeId) === record if ( (await worktreeCheckoutExists(record.worktreePath)) && - (!failed || (await isUnregisteredRemovalLeftover(record))) + (!failed || (await keepsFailedRow(record))) ) { leftovers.push({ path: record.worktreePath, @@ -77,10 +80,16 @@ export async function withUnregisteredRemovalCheckouts( return leftovers.length === 0 ? gitWorktrees : [...gitWorktrees, ...leftovers] } +// Unreadable proves nothing either way: the row stays, and Delete refuses until it can be read. +async function keepsFailedRow(record: WorktreeRemovalRecord): Promise { + const verdict = await unregisteredRemovalLeftoverVerdict(record) + return verdict === 'leftover' || verdict === 'unreadable' +} + /** * What a failed delete left at its path: `leftover`, the accepted checkout Git no longer * registers; `unregistered`, nothing Git registers and no leftover (a different folder, or none); - * `registered`, a checkout Git still registers; `unknown`, no answer. + * `registered`, a checkout Git still registers; `unknown`, Git or the disk gave no answer. */ export async function checkoutLeftByFailedRemoval( record: WorktreeRemovalRecord @@ -89,10 +98,11 @@ export async function checkoutLeftByFailedRemoval( if (await isCheckoutRegistered(record)) { return 'registered' } - return (await worktreeCheckoutExists(record.worktreePath)) && - (await isUnregisteredRemovalLeftover(record)) - ? 'leftover' - : 'unregistered' + if (!(await worktreeCheckoutExists(record.worktreePath))) { + return 'unregistered' + } + const verdict = await unregisteredRemovalLeftoverVerdict(record) + return verdict === 'leftover' ? verdict : verdict === 'unreadable' ? 'unknown' : 'unregistered' } catch (error) { console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) return 'unknown' From 09b012ecb44221536f679e4f5dea12586a45a9e7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:46:53 -0700 Subject: [PATCH 14/31] fix(worktrees): a failed delete leaves the disk only after its workspace's end is saved --- .../worktree-removal-failed-retry.spec.ts | 19 +++++++++++++++++++ src/main/runtime/orca-runtime.ts | 14 +++++++++----- src/main/worktree-background-removal.ts | 4 +++- src/main/worktree-removal-listing.ts | 6 ++++-- src/main/worktree-removal-table.ts | 16 ++++++++++------ 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index c9350524d992..5f0625125115 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -374,6 +374,25 @@ describe('runtime Delete after the user replaced a failed delete’s leftover', expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() }) + it('drops the failed delete from disk only once its workspace’s end is saved', async () => { + const { runtimeStore } = runtimeWithCreationMetadata() + let saved!: () => void + const flushPendingOrThrowAsync = vi.fn( + () => new Promise((resolve) => (saved = () => resolve())) + ) + const runtime = createWorktreeRemovalRuntime({ ...runtimeStore, flushPendingOrThrowAsync }) + + await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() + expect(flushPendingOrThrowAsync).toHaveBeenCalledOnce() + // A crash now must still find the record, or the saved metadata would outlive it. + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await readWorktreeRemovalRecords(directory)).toHaveLength(1) + + saved() + await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) + }) + it('keeps the workspace when Git registers a checkout at the path again after the scan', async () => { vi.mocked(listWorktreesStrict).mockResolvedValue([ { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ceac921c7245..2f1f081baaf8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -16,12 +16,16 @@ class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter { // Why: a listing can end a failed delete with no job to run this host's bookkeeping; it runs a // finished delete's, leaving the folder and branch. setUnfinishedWorktreeRemovalHost((record) => { - const host = this.store && this.localRemovalJobHost(this.store) - if (host) { - host.purge(record) - host.onRemoved(record) - host.publish(record.repoId) + const store = this.store + if (!store) { + return } + const host = this.localRemovalJobHost(store) + host.purge(record) + host.onRemoved(record) + host.publish(record.repoId) + // Why flush: the purge is saved on a delay, and the record must not leave the disk before it. + return store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) }) } } diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 1c345e6687aa..effc91f17619 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -250,6 +250,7 @@ async function settleBackgroundWorktreeRemoval( let settle: (settlement: RemovalSettlement) => void let failure: WorktreeRemovalRecord['failure'] let unregistered = false + let ended: Promise | undefined try { if (stopSignal.aborted) { return @@ -291,7 +292,7 @@ async function settleBackgroundWorktreeRemoval( runWorktreeChangeInvalidators(record.repoId) } else if (unregistered) { // Before the reply, so a Delete sent after it finds no workspace left at the path. - endUnfinishedWorktreeRemoval(record) + ended = endUnfinishedWorktreeRemoval(record) } } // Why this run's own settlement: desktop IPC and runtime RPC coalesce separately, so a concurrent @@ -303,6 +304,7 @@ async function settleBackgroundWorktreeRemoval( // re-runs a finish that re-derives what is left from Git. publishSafely(job.publish) if (cleared) { + await ended await persistWorktreeRemovalRecords() } } diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts index c082d4d17ddf..6816b5fb2119 100644 --- a/src/main/worktree-removal-listing.ts +++ b/src/main/worktree-removal-listing.ts @@ -59,6 +59,7 @@ export async function withUnregisteredRemovalCheckouts( } } let dropped = false + const endings: Promise[] = [] for (const record of notLeftovers) { // Why ask Git again: the rows may be a cached scan, and a checkout Git registers at the path // since is a new workspace. Unknowable keeps the record for the next listing to decide. @@ -71,11 +72,12 @@ export async function withUnregisteredRemovalCheckouts( failedWorktreeRemovals.delete(record.worktreeId) dropped = true if (!registered) { - endUnfinishedWorktreeRemoval(record) + endings.push(endUnfinishedWorktreeRemoval(record)) } } if (dropped) { - void persistWorktreeRemovalRecords() + // Why after the ends land: a crash before then keeps the record, and the next listing ends it. + void Promise.all(endings).then(() => persistWorktreeRemovalRecords()) } return leftovers.length === 0 ? gitWorktrees : [...gitWorktrees, ...leftovers] } diff --git a/src/main/worktree-removal-table.ts b/src/main/worktree-removal-table.ts index 187bc7260c42..642a507e71ee 100644 --- a/src/main/worktree-removal-table.ts +++ b/src/main/worktree-removal-table.ts @@ -14,15 +14,18 @@ export const failedWorktreeRemovals = new Map() // Why weak: a listing that read Git before a delete finished holds the record until it replies. export const finishedWorktreeRemovals = new WeakSet() let recordsDirectory: string | null = null -let endWorkspaceOnHost: ((record: WorktreeRemovalRecord) => void) | null = null +let endWorkspaceOnHost: ((record: WorktreeRemovalRecord) => Promise | void) | null = null export function setWorktreeRemovalRecordsDirectory(directory: string | null): void { recordsDirectory = directory } -/** The host's bookkeeping for a finished delete, minus the disk: metadata, history, events. */ +/** + * The host's bookkeeping for a finished delete, minus the disk: metadata, history, events. Settles + * once that bookkeeping is on disk. + */ export function setUnfinishedWorktreeRemovalHost( - endWorkspace: ((record: WorktreeRemovalRecord) => void) | null + endWorkspace: ((record: WorktreeRemovalRecord) => Promise | void) | null ): void { endWorkspaceOnHost = endWorkspace } @@ -31,15 +34,16 @@ export function setUnfinishedWorktreeRemovalHost( * Ends the workspace of a removal that stopped without deleting its checkout while Git does not * list the path: the folder there is not the one it accepted, or is gone. Leaves folder and branch. * Why: the workspace's creation metadata would otherwise let a later Delete of the same id remove - * whatever folder is at the path now. + * whatever folder is at the path now. Ends in this tick; settles once that is on disk, after which + * the record may be dropped from disk. */ -export function endUnfinishedWorktreeRemoval(record: WorktreeRemovalRecord): void { +export async function endUnfinishedWorktreeRemoval(record: WorktreeRemovalRecord): Promise { // A newer removal of the workspace owns it now. if (pendingWorktreeRemovals.has(record.worktreeId)) { return } try { - endWorkspaceOnHost?.(record) + await endWorkspaceOnHost?.(record) } catch (error) { console.warn(`[worktrees] could not end the workspace at ${record.worktreePath}`, error) } From 68d65989def954d1831bfcb4fad673e3b9896f73 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:48:19 -0700 Subject: [PATCH 15/31] fix(worktrees): a runtime Delete of a failed delete's row is told when Orca left the folder in place --- .../worktree-removal-failed-retry.spec.ts | 14 ++++++++++++++ .../runtime/runtime-worktree-removal-target.ts | 13 ++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 5f0625125115..4a2488ba55a1 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -292,6 +292,20 @@ describe('runtime Delete after the user replaced a failed delete’s leftover', await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) }) + it('tells a runtime Delete that the folder was left in place', async () => { + const { runtime } = runtimeWithCreationMetadata() + + await expect( + runtime.removeManagedWorktree(`id:${leftoverId}`, { + force: true, + waitForBackgroundRemoval: true + }) + ).rejects.toThrow(/is not the one Orca started deleting, so Orca left it in place/) + await _settlePendingWorktreeRemovalsForTests() + + expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) + }) + it('leaves the folder on a Delete after another client’s listing ended the failed delete', async () => { const { runtime, runtimeStore } = runtimeWithCreationMetadata() diff --git a/src/main/runtime/runtime-worktree-removal-target.ts b/src/main/runtime/runtime-worktree-removal-target.ts index a4ae8d9c4370..1a5d0e04e7da 100644 --- a/src/main/runtime/runtime-worktree-removal-target.ts +++ b/src/main/runtime/runtime-worktree-removal-target.ts @@ -4,7 +4,8 @@ import { type RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' import type { ResolvedWorktree } from './runtime-worktree-path-identity' -import type { ExecutionHostId } from '../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { failedWorktreeRemovals } from '../worktree-removal-table' export async function resolveRuntimeWorktreeRemovalTarget(args: { selector: string @@ -16,6 +17,16 @@ export async function resolveRuntimeWorktreeRemovalTarget(args: { ) => Promise requiredHostId?: ExecutionHostId }): Promise { + const failedTarget = parseExactWorktreeIdSelector(args.selector) + // Why before resolving: that listing ends a failed delete whose folder was replaced, so Delete + // would answer not found instead of its retry saying the folder was left in place. + if ( + failedTarget && + (args.requiredHostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID && + failedWorktreeRemovals.has(failedTarget.id) + ) { + return failedTarget + } try { const exactTarget = parseExactWorktreeIdSelector(args.selector) const worktree = From 44c2d786bd6aa6012e5ed67a04b999d6488b142e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:49:51 -0700 Subject: [PATCH 16/31] test(worktrees): the desktop Delete test's stand-in host returns nothing --- src/main/ipc/worktrees-orphan-directory-cleanup.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index 4b700d7dc172..f70c9ae4a9ab 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -406,9 +406,9 @@ describe('registerWorktreeHandlers', () => { return { stdout: '', stderr: '' } }) // Stands in for the runtime, which registers the bookkeeping that ends such a workspace. - setUnfinishedWorktreeRemovalHost((record) => + setUnfinishedWorktreeRemovalHost((record) => { store.removeWorktreeMeta(record.worktreeId, 'local') - ) + }) vi.spyOn(console, 'warn').mockImplementation(() => {}) // The user deletes the leftover by hand and makes a folder of their own at the path. await rm(leftoverPath, { recursive: true }) From 122aac41c416fdb09ed7b7a08debc36874ad5344 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:05:41 -0700 Subject: [PATCH 17/31] fix(worktrees): a Delete of a failed delete's leftover whose .git Orca cannot read says so --- src/main/ipc/worktrees-failed-removal.test.ts | 1 - ...interrupted-local-worktree-removal.test.ts | 39 ++++++++++++++++++- ...time-interrupted-local-worktree-removal.ts | 12 +++--- src/main/worktree-removal-leftover.ts | 10 +---- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index 39c32dd043cb..6da51f31ab3d 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -115,7 +115,6 @@ vi.mock('../worktree-removal-table', async (importOriginal) => ({ })) vi.mock('../worktree-removal-leftover', async (importOriginal) => ({ ...(await importOriginal()), - isUnregisteredRemovalLeftover: vi.fn(async () => true), unregisteredRemovalLeftoverVerdict: vi.fn(async () => 'leftover') })) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index be30f854e974..009c574dc2ee 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -113,7 +113,9 @@ type FinishOutcome = | ({ status: 'removed' } & RemoveWorktreeResult) | { status: 'failed'; error: string } -async function finishAfterRestart(options: { repoGone?: boolean; head?: string } = {}): Promise<{ +async function finishAfterRestart( + options: { repoGone?: boolean; head?: string; keepsFailedRecord?: boolean } = {} +): Promise<{ outcome: FinishOutcome purged: string[] remember: ReturnType @@ -171,7 +173,9 @@ async function finishAfterRestart(options: { repoGone?: boolean; head?: string } (error: unknown) => ({ status: 'failed' as const, error: String(error) }) ) await _settlePendingWorktreeRemovalsForTests() - expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + if (!options.keepsFailedRecord) { + expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + } expect(waitForPendingWorktreeRemoval(record.worktreeId)).toBeUndefined() // Released on every outcome, including a finish that ended before taking its own gate. beginTerminalInstall(worktreePath)() @@ -344,6 +348,37 @@ describe('finishing an interrupted worktree removal after a restart', () => { } }) + it('says it could not read an unregistered leftover whose .git it cannot read', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await unlink(join(worktreePath, '.git')) + await git(['worktree', 'prune']) + const gitLink = join(worktreePath, '.git') + const { lstat: readLstat } = await vi.importActual('node:fs/promises') + const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + vi.mocked(lstat).mockImplementation((path, options) => + path === gitLink ? Promise.reject(denied) : readLstat(path, options) + ) + + try { + const { outcome, purged } = await finishAfterRestart({ keepsFailedRecord: true }) + + // Not "a different checkout": deleting it again would be refused the same way. + expect(outcome).toMatchObject({ + status: 'failed', + error: expect.stringMatching(/could not read the folder/) + }) + // The row keeps that text until the folder can be read. + expect(await readWorktreeRemovalRecords(recordsDir)).toMatchObject([ + { failure: { message: expect.stringMatching(/could not read the folder/) } } + ]) + expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + } finally { + vi.mocked(lstat).mockReset() + } + }) + it('leaves an unregistered folder an older build recorded without an identity', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) acceptedIdentity = undefined diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index ea9be2543a0d..3d8ecd467e2c 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -22,7 +22,7 @@ import { assertUnregisteredRemovalLeftover, differentCheckoutAtPathError, differentFolderAtPathError, - isUnregisteredRemovalLeftover, + unregisteredRemovalLeftoverVerdict, unreadableFolderAtPathError } from '../worktree-removal-leftover' import { matchCheckoutDirectory } from '../worktree-checkout-identity' @@ -165,11 +165,11 @@ async function finishInterruptedLocalWorktreeRemoval( } // At an unregistered path, only a `.git` naming the admin entry Git removed is this checkout's // own leftover (Git drops the registration even when its delete fails partway). - if ( - deletable - ? !isRecordedCheckout(deletable, record) - : !(await isUnregisteredRemovalLeftover(leftoverRecord)) - ) { + const leftover = deletable ? undefined : await unregisteredRemovalLeftoverVerdict(leftoverRecord) + if (leftover === 'unreadable') { + throw unreadableFolderAtPathError(record.worktreePath) + } + if (deletable ? !isRecordedCheckout(deletable, record) : leftover !== 'leftover') { throw differentCheckoutAtPathError(record.worktreePath) } // Why: Git deletes `.git` wherever it falls in directory order (early on NTFS) and refuses to diff --git a/src/main/worktree-removal-leftover.ts b/src/main/worktree-removal-leftover.ts index ceaf98c7c50a..e23e5b799254 100644 --- a/src/main/worktree-removal-leftover.ts +++ b/src/main/worktree-removal-leftover.ts @@ -20,15 +20,9 @@ type RemovalLeftoverRecord = Pick< /** * Whether a checkout path Git no longer registers still holds the removed checkout's own leftover: * the very directory the removal accepted (or nothing at all), with no `.git` (Git deleted it first) - * or a `.git` file naming the admin entry Git removed. Anything else was put at the path since. + * or a `.git` file naming the admin entry Git removed. Anything else was put at the path since, + * unless the path could not be read, which proves neither. */ -export async function isUnregisteredRemovalLeftover( - record: RemovalLeftoverRecord -): Promise { - return (await unregisteredRemovalLeftoverVerdict(record)) === 'leftover' -} - -/** As isUnregisteredRemovalLeftover, telling a path that could not be read from a different one. */ export async function unregisteredRemovalLeftoverVerdict( record: RemovalLeftoverRecord ): Promise<'leftover' | 'unreadable' | 'different-folder' | 'different-checkout'> { From 51cad53d71db705b44c22d62c565b6b61cf509fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:05:48 -0700 Subject: [PATCH 18/31] Revert "fix(worktrees): a runtime Delete of a failed delete's row is told when Orca left the folder in place" This reverts commit 68d65989def954d1831bfcb4fad673e3b9896f73. The shortcut reaches no real client: the CLI and mobile list the workspace before deleting it, which ends the failed delete first, so they still answer not found (the folder is kept either way). --- .../worktree-removal-failed-retry.spec.ts | 14 -------------- .../runtime/runtime-worktree-removal-target.ts | 13 +------------ 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 4a2488ba55a1..5f0625125115 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -292,20 +292,6 @@ describe('runtime Delete after the user replaced a failed delete’s leftover', await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) }) - it('tells a runtime Delete that the folder was left in place', async () => { - const { runtime } = runtimeWithCreationMetadata() - - await expect( - runtime.removeManagedWorktree(`id:${leftoverId}`, { - force: true, - waitForBackgroundRemoval: true - }) - ).rejects.toThrow(/is not the one Orca started deleting, so Orca left it in place/) - await _settlePendingWorktreeRemovalsForTests() - - expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) - }) - it('leaves the folder on a Delete after another client’s listing ended the failed delete', async () => { const { runtime, runtimeStore } = runtimeWithCreationMetadata() diff --git a/src/main/runtime/runtime-worktree-removal-target.ts b/src/main/runtime/runtime-worktree-removal-target.ts index 1a5d0e04e7da..a4ae8d9c4370 100644 --- a/src/main/runtime/runtime-worktree-removal-target.ts +++ b/src/main/runtime/runtime-worktree-removal-target.ts @@ -4,8 +4,7 @@ import { type RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' import type { ResolvedWorktree } from './runtime-worktree-path-identity' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' -import { failedWorktreeRemovals } from '../worktree-removal-table' +import type { ExecutionHostId } from '../../shared/execution-host' export async function resolveRuntimeWorktreeRemovalTarget(args: { selector: string @@ -17,16 +16,6 @@ export async function resolveRuntimeWorktreeRemovalTarget(args: { ) => Promise requiredHostId?: ExecutionHostId }): Promise { - const failedTarget = parseExactWorktreeIdSelector(args.selector) - // Why before resolving: that listing ends a failed delete whose folder was replaced, so Delete - // would answer not found instead of its retry saying the folder was left in place. - if ( - failedTarget && - (args.requiredHostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID && - failedWorktreeRemovals.has(failedTarget.id) - ) { - return failedTarget - } try { const exactTarget = parseExactWorktreeIdSelector(args.selector) const worktree = From a5fc147a64e4b4cfa098908fdde32728d3681ba3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:13:00 -0700 Subject: [PATCH 19/31] fix(worktrees): a failed delete Git could not be asked about is dropped once Git says it still registers the checkout --- src/main/worktree-failed-removal.test.ts | 29 +++++++++++++++++++++++- src/main/worktree-removal-listing.ts | 23 +++++++++++++------ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 13b944d9c5be..7c1a7958028a 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -28,7 +28,7 @@ import { } from './worktree-removal-listing' import { readWorktreeRemovalRecords, writeWorktreeRemovalRecords } from './worktree-removal-records' import { readCheckoutDirectoryIdentity } from './worktree-checkout-identity' -import { setUnfinishedWorktreeRemovalHost } from './worktree-removal-table' +import { failedWorktreeRemovals, setUnfinishedWorktreeRemovalHost } from './worktree-removal-table' import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-records-load' vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) @@ -151,6 +151,33 @@ describe('a delete that fails after Git dropped the registration', () => { expect(endWorkspace).not.toHaveBeenCalled() }) + it('drops a failed delete Git could not be asked about once Git says it still registers the checkout', async () => { + const endWorkspace = vi.fn() + setUnfinishedWorktreeRemovalHost(endWorkspace) + const checkoutRow = { ...leftoverRow(), removalError: undefined } + vi.mocked(listWorktreesStrict).mockRejectedValueOnce(new Error('git worktree list timed out')) + await failRemoval() + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) + const listRegistered = () => + withUnregisteredRemovalCheckouts('repo-1', [mainWorktree, checkoutRow]) + + // A cached scan can still list a checkout Git dropped, so the rows alone never decide. + expect(await listRegistered()).toEqual([mainWorktree, checkoutRow]) + vi.mocked(listWorktreesStrict).mockRejectedValueOnce(new Error('git worktree list timed out')) + expect(await listRegistered()).toEqual([mainWorktree, checkoutRow]) + expect(failedWorktreeRemovals.has(worktreeId)).toBe(true) + expect(endWorkspace).not.toHaveBeenCalled() + + vi.mocked(listWorktreesStrict).mockResolvedValue([mainWorktree, checkoutRow]) + expect(await listRegistered()).toEqual([mainWorktree, checkoutRow]) + expect(failedWorktreeRemovals.has(worktreeId)).toBe(false) + await vi.waitFor(async () => + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + ) + // The workspace is still Git's. + expect(endWorkspace).not.toHaveBeenCalled() + }) + it('clears the record as before when the checkout is gone', async () => { await rm(checkout, { recursive: true }) await failRemoval() diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts index 6816b5fb2119..4d8c11ec6555 100644 --- a/src/main/worktree-removal-listing.ts +++ b/src/main/worktree-removal-listing.ts @@ -24,18 +24,23 @@ const NO_PENDING_REMOVALS: PendingWorktreeRemovals = new Map() * Git's rows for a local repo plus one for each removal this host still owns whose checkout Git no * longer lists but is still on disk: a failed delete (carrying its error) or one still finishing. * A failed delete ends here, with its workspace, once its checkout is gone or a different folder - * took the path. + * took the path, and on its own once Git registers its path again. */ export async function withUnregisteredRemovalCheckouts( repoId: string, gitWorktrees: GitWorktreeInfo[] ): Promise { + const listsPath = (record: WorktreeRemovalRecord): boolean => + gitWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath)) const unlisted = [...pendingWorktreeRemovals.values(), ...failedWorktreeRemovals.values()].filter( - (record) => - record.repoId === repoId && - !gitWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath)) + (record) => record.repoId === repoId && !listsPath(record) ) - if (unlisted.length === 0) { + // Why: a delete that failed while Git could not be asked stays failed though Git may still + // register its checkout, and nothing else asks Git again. + const listedFailures = [...failedWorktreeRemovals.values()].filter( + (record) => record.repoId === repoId && listsPath(record) + ) + if (unlisted.length === 0 && listedFailures.length === 0) { return gitWorktrees } const leftovers: GitWorktreeInfo[] = [] @@ -60,13 +65,17 @@ export async function withUnregisteredRemovalCheckouts( } let dropped = false const endings: Promise[] = [] - for (const record of notLeftovers) { + for (const record of [...notLeftovers, ...listedFailures]) { // Why ask Git again: the rows may be a cached scan, and a checkout Git registers at the path // since is a new workspace. Unknowable keeps the record for the next listing to decide. const registered = await isCheckoutRegistered(record).catch(() => undefined) // Read again: a Delete may have retried it meanwhile. No await from here to the end, or a // Delete in between would find neither the record nor the end of its workspace. - if (registered === undefined || failedWorktreeRemovals.get(record.worktreeId) !== record) { + if ( + registered === undefined || + (!registered && listedFailures.includes(record)) || + failedWorktreeRemovals.get(record.worktreeId) !== record + ) { continue } failedWorktreeRemovals.delete(record.worktreeId) From 462b8e943f8fe136921fcde51044fff955247ce4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:28:57 -0700 Subject: [PATCH 20/31] revert to main before the standard-model rework --- .../git/worktree-add-pending-removal.test.ts | 3 +- src/main/git/worktree-operation-options.ts | 2 - src/main/git/worktree-removal.ts | 2 - .../ipc/worktrees-background-removal.test.ts | 3 +- src/main/ipc/worktrees-failed-removal.test.ts | 5 +- ...worktrees-orphan-directory-cleanup.test.ts | 90 +------- .../remove-registered-local-worktree.ts | 4 - ...worktree-removal-archive-hook-gate.spec.ts | 3 +- .../worktree-removal-failed-retry.spec.ts | 210 +---------------- src/main/runtime/orca-runtime.ts | 15 -- ...worktree-removal-marker-projection.test.ts | 3 +- ...time-failed-local-worktree-removal.test.ts | 149 +----------- ...interrupted-local-worktree-removal.test.ts | 158 +------------ ...time-interrupted-local-worktree-removal.ts | 53 +---- ...ntime-registered-local-worktree-removal.ts | 27 +-- ...orktree-background-removal-records.test.ts | 50 +--- src/main/worktree-background-removal.test.ts | 3 +- src/main/worktree-background-removal.ts | 53 +++-- src/main/worktree-checkout-identity.test.ts | 129 ----------- src/main/worktree-checkout-identity.ts | 79 ------- src/main/worktree-failed-removal.test.ts | 216 +----------------- src/main/worktree-removal-leftover.ts | 68 +----- src/main/worktree-removal-listing.ts | 84 +------ src/main/worktree-removal-records.ts | 14 -- src/main/worktree-removal-table.ts | 30 --- 25 files changed, 92 insertions(+), 1361 deletions(-) delete mode 100644 src/main/worktree-checkout-identity.test.ts delete mode 100644 src/main/worktree-checkout-identity.ts diff --git a/src/main/git/worktree-add-pending-removal.test.ts b/src/main/git/worktree-add-pending-removal.test.ts index 30185a2d2098..fdb115979246 100644 --- a/src/main/git/worktree-add-pending-removal.test.ts +++ b/src/main/git/worktree-add-pending-removal.test.ts @@ -31,8 +31,7 @@ function startDeleting(): void { repoPath: '/repo', worktree: { path: '/repo-feature', branch: 'refs/heads/feature/test', head: 'abc' }, deleteBranch: true, - force: false, - checkoutIdentity: undefined + force: false }, run: () => new Promise(() => {}), publish: () => {} diff --git a/src/main/git/worktree-operation-options.ts b/src/main/git/worktree-operation-options.ts index ce8135e1ebfa..82c37a1a679d 100644 --- a/src/main/git/worktree-operation-options.ts +++ b/src/main/git/worktree-operation-options.ts @@ -44,8 +44,6 @@ export type RemoveWorktreeOptions = GitWorktreeExecOptions & { knownRemovedWorktree?: Pick /** Stops only `git worktree remove`; a killed branch cleanup could strand a ref lock. */ checkoutDeleteSignal?: AbortSignal - /** Runs in the delete slot right before Git deletes; a throw refuses the delete. */ - assertCheckoutBeforeDelete?: () => Promise } // Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); ample for an ordinary large checkout, but not one behind a slow content filter (#12696). diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 9d35fad85dee..2f7eb8c38e00 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -80,8 +80,6 @@ async function performRemoveWorktree( } args.push(worktreePath) await runUnderWorktreeDeleteLimit(async () => { - // Why in the slot: the wait can outlast two large deletes, and the path may change meanwhile. - await options.assertCheckoutBeforeDelete?.() await gitExecFileAsync(args, execOptions) await removeCheckoutLeftByGit(worktreePath, options) }) diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index d03c032864fb..ead826e7d629 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -267,8 +267,7 @@ describe('worktrees:remove in the background', () => { repoPath: '/workspace/repo', worktree: feature, deleteBranch: true, - force: false, - checkoutIdentity: undefined + force: false }, run: () => new Promise((resolve) => { diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index 6da51f31ab3d..f1cfa13eb803 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -115,7 +115,7 @@ vi.mock('../worktree-removal-table', async (importOriginal) => ({ })) vi.mock('../worktree-removal-leftover', async (importOriginal) => ({ ...(await importOriginal()), - unregisteredRemovalLeftoverVerdict: vi.fn(async () => 'leftover') + isUnregisteredRemovalLeftover: vi.fn(async () => true) })) const featureId = 'repo-1::/workspace/feature-wt' @@ -142,8 +142,7 @@ async function failAfterGitDroppedIt(): Promise { repoPath: '/workspace/repo', worktree: feature, deleteBranch: true, - force: false, - checkoutIdentity: undefined + force: false }, run: async () => { listWorktreesMock.mockResolvedValue([main]) diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index f70c9ae4a9ab..4eb08162a8a6 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -1,15 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync } from 'node:fs' -import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { - _settlePendingWorktreeRemovalsForTests, - loadWorktreeRemovalRecords -} from '../worktree-background-removal' -import { readCheckoutDirectoryIdentity } from '../worktree-checkout-identity' -import { writeWorktreeRemovalRecords } from '../worktree-removal-records' -import { setUnfinishedWorktreeRemovalHost } from '../worktree-removal-table' import { removeWorktreeMock, getEffectiveHooksMock, @@ -353,86 +345,6 @@ describe('registerWorktreeHandlers', () => { } }) - it('leaves a folder the user put at a failed delete’s path, on Delete and every Delete after', async () => { - const parentDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-ipc-replaced-leftover-'))) - const repoPath = join(parentDir, 'repo') - const leftoverPath = join(parentDir, 'leftover') - const worktreeId = `repo-1::${leftoverPath}` - await mkdir(join(leftoverPath, 'node_modules'), { recursive: true }) - await mkdir(join(parentDir, 'profile')) - const checkoutIdentity = await readCheckoutDirectoryIdentity(leftoverPath) - await writeWorktreeRemovalRecords(join(parentDir, 'profile'), () => [ - { - worktreeId, - repoId: 'repo-1', - repoPath, - worktreePath: leftoverPath, - branch: 'feature', - head: 'abc', - deleteBranch: true, - force: true, - requestedAt: 1, - checkoutIdentity, - failure: { message: 'Operation not permitted', failedAt: 2 } - } - ]) - await loadWorktreeRemovalRecords(join(parentDir, 'profile')) - const repo = { - id: 'repo-1', - path: repoPath, - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - worktreeBaseRef: null - } - store.getRepo.mockReturnValue(repo) - store.getRepos.mockReturnValue([repo]) - mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath) - // What Orca keeps for every workspace it created, which alone authorizes deleting the path. - let meta: Record | undefined = makeWorktreeMeta({ - orcaCreatedAt: Date.now(), - orcaCreationSource: 'runtime' - }) - store.getWorktreeMeta.mockImplementation((id: string) => (id === worktreeId ? meta : undefined)) - store.removeWorktreeMeta.mockImplementation((id: string) => { - if (id === worktreeId) { - meta = undefined - } - }) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'status') { - throw new Error('fatal: not a git repository') - } - return { stdout: '', stderr: '' } - }) - // Stands in for the runtime, which registers the bookkeeping that ends such a workspace. - setUnfinishedWorktreeRemovalHost((record) => { - store.removeWorktreeMeta(record.worktreeId, 'local') - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - // The user deletes the leftover by hand and makes a folder of their own at the path. - await rm(leftoverPath, { recursive: true }) - await mkdir(leftoverPath) - await writeFile(join(leftoverPath, 'notes.txt'), 'mine\n') - - try { - await expect(handlers['worktrees:remove'](null, { worktreeId, force: true })).rejects.toThrow( - /is not the one Orca started deleting/ - ) - await _settlePendingWorktreeRemovalsForTests() - // A second window, or this one before its row refreshes, still shows the workspace. - await expect(handlers['worktrees:remove'](null, { worktreeId, force: true })).rejects.toThrow( - `Refusing to delete unregistered worktree path: ${leftoverPath}` - ) - - expect(existsSync(join(leftoverPath, 'notes.txt'))).toBe(true) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') - } finally { - setUnfinishedWorktreeRemovalHost(null) - await rm(parentDir, { recursive: true, force: true }) - } - }) - it('rejects an Orca-created unregistered local directory with a git directory', async () => { const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-standalone-')) const repoPath = join(parentDir, 'repo') diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index 834d9f082973..a75a2607c9e5 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -44,7 +44,6 @@ import { removesInBackground, startBackgroundWorktreeRemoval } from '../../../worktree-background-removal' -import { readCheckoutDirectoryIdentity } from '../../../worktree-checkout-identity' export async function removeRegisteredLocalWorktree( context: WorktreeIpcContext, @@ -152,15 +151,12 @@ export async function removeRegisteredLocalWorktree( } // Why detached: every refusal above already ran, and Git's 20-35 s delete must finish even when the // request that asked for it goes away; other views read the host's `removing` marker meanwhile. - // Why: a later retry or resume may only delete this directory, never one made at the path since. - const checkoutIdentity = await readCheckoutDirectoryIdentity(refreshedRegisteredWorktree.path) void startBackgroundWorktreeRemoval({ removal: { worktreeId: args.worktreeId, repoId, repoPath: repo.path, worktree: refreshedRegisteredWorktree, - checkoutIdentity, deleteBranch, force: args.force ?? false }, diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts index 69e59851aa30..ee5d67745e64 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts @@ -255,8 +255,7 @@ describe('archive hook removal gate', () => { repoPath: TEST_REPO_PATH, worktree: { path: TEST_WORKTREE_PATH, branch: 'feature', head: 'feature' }, deleteBranch: true, - force: false, - checkoutIdentity: undefined + force: false }, run: () => desktopDelete.promise, publish: () => {} diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 5f0625125115..00cd9ad82226 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -5,7 +5,6 @@ import { existsSync } from 'node:fs' import { realpath } from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - gitRunner, join, listWorktreesStrict, mkdir, @@ -13,14 +12,9 @@ import { removeWorktree, rm, scanLocalRepoWorktreesForResolutionMock, - tmpdir, - writeFile + tmpdir } from '../orca-runtime-test-mocks.spec' -import { - TEST_REPO_ID, - TEST_REPO_PATH, - createStaleRuntimeWorktreeStore -} from '../orca-runtime-test-fixtures.spec' +import { TEST_REPO_ID, TEST_REPO_PATH } from '../orca-runtime-test-fixtures.spec' import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' import { _resetPendingWorktreeRemovalsForTests, @@ -32,7 +26,6 @@ import { readWorktreeRemovalRecords, writeWorktreeRemovalRecords } from '../../worktree-removal-records' -import { readCheckoutDirectoryIdentity } from '../../worktree-checkout-identity' const FAILURE = "error: failed to delete 'node_modules/a/LICENSE': Operation not permitted" @@ -47,7 +40,6 @@ describe('runtime Delete on a failed delete’s leftover', () => { leftover = join(directory, 'feature') leftoverId = `${TEST_REPO_ID}::${leftover}` await mkdir(join(leftover, 'node_modules'), { recursive: true }) - const checkoutIdentity = await readCheckoutDirectoryIdentity(leftover) await writeWorktreeRemovalRecords(directory, () => [ { worktreeId: leftoverId, @@ -59,7 +51,6 @@ describe('runtime Delete on a failed delete’s leftover', () => { deleteBranch: true, force: true, requestedAt: 1, - checkoutIdentity, failure: { message: FAILURE, failedAt: 2 } } ]) @@ -215,200 +206,3 @@ describe('runtime listing straight after a delete fails partway', () => { }) }) }) - -describe('runtime Delete after the user replaced a failed delete’s leftover', () => { - let directory = '' - let leftover = '' - let leftoverId = '' - - beforeEach(async () => { - vi.clearAllMocks() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - directory = await realpath(await mkdtemp(join(tmpdir(), 'orca-runtime-replaced-leftover-'))) - leftover = join(directory, 'feature') - leftoverId = `${TEST_REPO_ID}::${leftover}` - await mkdir(join(leftover, 'node_modules'), { recursive: true }) - const checkoutIdentity = await readCheckoutDirectoryIdentity(leftover) - await writeWorktreeRemovalRecords(directory, () => [ - { - worktreeId: leftoverId, - repoId: TEST_REPO_ID, - repoPath: TEST_REPO_PATH, - worktreePath: leftover, - branch: 'feature', - head: 'abc', - deleteBranch: true, - force: true, - requestedAt: 1, - checkoutIdentity, - failure: { message: FAILURE, failedAt: 2 } - } - ]) - await loadWorktreeRemovalRecords(directory) - vi.mocked(listWorktreesStrict).mockResolvedValue([]) - vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { - if (args[0] === 'status') { - throw new Error('fatal: not a git repository') - } - return { stdout: '', stderr: '' } - }) - // The user deletes the leftover by hand and makes a folder of their own at the path. - await rm(leftover, { recursive: true, force: true }) - await mkdir(leftover) - await writeFile(join(leftover, 'notes.txt'), 'mine\n') - }) - - afterEach(async () => { - await _settlePendingWorktreeRemovalsForTests() - _resetPendingWorktreeRemovalsForTests() - vi.restoreAllMocks() - await rm(directory, { recursive: true, force: true }) - }) - - // What Orca keeps for every workspace it created, which alone once authorized deleting the path. - function runtimeWithCreationMetadata() { - const stale = createStaleRuntimeWorktreeStore(leftoverId, { - orcaCreatedAt: Date.now(), - orcaCreationSource: 'runtime' - }) - return { runtime: createWorktreeRemovalRuntime(stale.runtimeStore), ...stale } - } - - async function deleteById(runtime: ReturnType) { - await runtime - .removeManagedWorktree(`id:${leftoverId}`, { force: true, waitForBackgroundRemoval: true }) - .catch(() => {}) - await _settlePendingWorktreeRemovalsForTests() - } - - it('leaves the folder on Delete, and on every Delete after, ending the workspace', async () => { - const { runtime, runtimeStore } = runtimeWithCreationMetadata() - - await deleteById(runtime) - await deleteById(runtime) - - expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) - expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() - await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) - }) - - it('leaves the folder on a Delete after another client’s listing ended the failed delete', async () => { - const { runtime, runtimeStore } = runtimeWithCreationMetadata() - - const listed = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - expect(listed.worktrees.some((row) => row.id === leftoverId)).toBe(false) - await deleteById(runtime) - - expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) - expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() - await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) - }) - - it('leaves the folder on a Delete after a listing could not ask Git about the failed delete', async () => { - vi.mocked(listWorktreesStrict).mockRejectedValueOnce(new Error('git worktree list timed out')) - const { runtime, runtimeStore } = runtimeWithCreationMetadata() - - await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - // Kept for the next listing to decide, rather than dropped with the workspace left behind. - expect(await readWorktreeRemovalRecords(directory)).toHaveLength(1) - await deleteById(runtime) - - expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) - expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() - }) - - it('leaves the folder on a Delete made while a listing asks Git about the failed delete', async () => { - let answerGit!: () => void - let askedGit!: () => void - const listingAskedGit = new Promise((resolve) => (askedGit = resolve)) - vi.mocked(listWorktreesStrict).mockImplementationOnce(() => { - askedGit() - return new Promise((resolve) => (answerGit = () => resolve([]))) - }) - const { runtime, runtimeStore } = runtimeWithCreationMetadata() - - const listing = runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - await listingAskedGit - await deleteById(runtime) - const notesAfterDelete = existsSync(join(leftover, 'notes.txt')) - answerGit() - await listing - - expect(notesAfterDelete).toBe(true) - expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() - }) - - it('leaves the folder on a Delete after a restart’s refused delete could not ask Git what is left', async () => { - // The delete was still running when Orca quit; the user replaced the folder before the restart. - const [{ failure: _failure, ...interrupted }] = await readWorktreeRemovalRecords(directory) - _resetPendingWorktreeRemovalsForTests() - await writeWorktreeRemovalRecords(directory, () => [interrupted]) - await loadWorktreeRemovalRecords(directory) - vi.mocked(listWorktreesStrict) - .mockResolvedValueOnce([]) - .mockRejectedValueOnce(new Error('git worktree list timed out')) - const { runtime, runtimeStore } = runtimeWithCreationMetadata() - - runtime.finishInterruptedWorktreeRemovals() - await _settlePendingWorktreeRemovalsForTests() - expect(await readWorktreeRemovalRecords(directory)).toMatchObject([ - { worktreeId: leftoverId, failure: { message: expect.stringMatching(/is not the one Orca/) } } - ]) - await deleteById(runtime) - - expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) - expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() - }) - - it('leaves a folder made at the path after a restart found the leftover gone', async () => { - await rm(leftover, { recursive: true, force: true }) - _resetPendingWorktreeRemovalsForTests() - await loadWorktreeRemovalRecords(directory) - await mkdir(leftover) - await writeFile(join(leftover, 'notes.txt'), 'mine\n') - const { runtime, runtimeStore } = runtimeWithCreationMetadata() - - await deleteById(runtime) - - expect(existsSync(join(leftover, 'notes.txt'))).toBe(true) - expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() - }) - - it('drops the failed delete from disk only once its workspace’s end is saved', async () => { - const { runtimeStore } = runtimeWithCreationMetadata() - let saved!: () => void - const flushPendingOrThrowAsync = vi.fn( - () => new Promise((resolve) => (saved = () => resolve())) - ) - const runtime = createWorktreeRemovalRuntime({ ...runtimeStore, flushPendingOrThrowAsync }) - - await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - expect(runtimeStore.getWorktreeMeta(leftoverId)).toBeUndefined() - expect(flushPendingOrThrowAsync).toHaveBeenCalledOnce() - // A crash now must still find the record, or the saved metadata would outlive it. - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(await readWorktreeRemovalRecords(directory)).toHaveLength(1) - - saved() - await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) - }) - - it('keeps the workspace when Git registers a checkout at the path again after the scan', async () => { - vi.mocked(listWorktreesStrict).mockResolvedValue([ - { - path: leftover, - head: 'def', - branch: 'refs/heads/other', - isBare: false, - isMainWorktree: false - } - ]) - scanLocalRepoWorktreesForResolutionMock.mockResolvedValue({ ok: true, worktrees: [] }) - const { runtime, removeWorktreeMeta } = runtimeWithCreationMetadata() - - await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - - await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) - expect(removeWorktreeMeta).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2f1f081baaf8..287285c8ea70 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3,7 +3,6 @@ import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import { registerWorktreeChangeInvalidator } from '../ipc/worktree-change-invalidators' import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation' -import { setUnfinishedWorktreeRemovalHost } from '../worktree-removal-table' class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter { constructor(...args: ConstructorParameters) { @@ -13,20 +12,6 @@ class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter { // module registers the generation bump at load; a headless host never loads it. registerDetectedWorktreeScanInvalidation() registerWorktreeChangeInvalidator((repoId) => this.invalidateWorktreeCatalog(repoId)) - // Why: a listing can end a failed delete with no job to run this host's bookkeeping; it runs a - // finished delete's, leaving the folder and branch. - setUnfinishedWorktreeRemovalHost((record) => { - const store = this.store - if (!store) { - return - } - const host = this.localRemovalJobHost(store) - host.purge(record) - host.onRemoved(record) - host.publish(record.repoId) - // Why flush: the purge is saved on a delay, and the record must not leave the disk before it. - return store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) - }) } } type OrcaRuntimeServiceExport = RuntimeCommandSurfaceHost diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts index 88490f9cb6f3..12e4bf0f7a3e 100644 --- a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts @@ -55,8 +55,7 @@ describe('worktree listings while a checkout is being deleted', () => { repoPath: '/work/repo', worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, deleteBranch: true, - force: false, - checkoutIdentity: undefined + force: false }, run: () => new Promise(() => {}), publish: () => {} diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 06baddd99a2b..2aad80e10915 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -4,7 +4,7 @@ // the file's owner; worktree-failed-removal.test.ts covers the same rules with Git mocked. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -38,7 +38,6 @@ import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from '../worktree-removal-records' -import { readCheckoutDirectoryIdentity } from '../worktree-checkout-identity' import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' vi.mock('../project-runtime-git-options', () => ({ @@ -121,8 +120,7 @@ async function finishAtStartup(purged: string[]): Promise { head: (await git(['rev-parse', 'feature'])).trim(), deleteBranch: true, force: true, - requestedAt: 1, - checkoutIdentity: await readCheckoutDirectoryIdentity(worktreePath) + requestedAt: 1 } await writeWorktreeRemovalRecords(recordsDir, () => [record]) await loadWorktreeRemovalRecords(recordsDir) @@ -146,7 +144,6 @@ async function failInSession(): Promise { repoId: repo.id, repoPath, worktree: { path: worktreePath, branch: 'refs/heads/feature', head: 'abc' }, - checkoutIdentity: await readCheckoutDirectoryIdentity(worktreePath), deleteBranch: true, force: true }, @@ -267,8 +264,8 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part it('never deletes a different checkout created at the path since', async () => { await failInSession() await setImmutable(false) - // In the leftover's own folder, so only its `.git` tells it apart. - await rm(join(worktreePath, '.git')) + await rm(worktreePath, { recursive: true }) + await mkdir(worktreePath) await git(['init', '-q'], worktreePath) await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') const purged: string[] = [] @@ -287,52 +284,6 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await listedRows()).toEqual([]) }) - it('never deletes an ordinary folder the user put at the path since', async () => { - await failInSession() - await setImmutable(false) - // No `.git`, as the leftover may have none: only the directory's identity tells them apart. - await rm(worktreePath, { recursive: true }) - await mkdir(worktreePath) - await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') - const purged: string[] = [] - - // Delete before any listing noticed: the retry refuses and lets the record go. - const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => - interruptedLocalWorktreeRemovalJob(record, jobHost(purged)) - ) - await expect(retried).rejects.toThrow(/is not the one Orca started deleting/) - await _settlePendingWorktreeRemovalsForTests() - - expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() - expect(purged).toEqual([]) - expect(await git(['branch', '--list', 'feature'])).not.toBe('') - expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) - expect(await listedRows()).toEqual([]) - }) - - it('never deletes a failed delete’s folder that an older build recorded without an identity', async () => { - await failInSession() - await setImmutable(false) - const [{ checkoutIdentity: _identity, ...older }] = await readWorktreeRemovalRecords(recordsDir) - _resetPendingWorktreeRemovalsForTests() - await writeWorktreeRemovalRecords(recordsDir, () => [older]) - await loadWorktreeRemovalRecords(recordsDir) - const purged: string[] = [] - - const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => - interruptedLocalWorktreeRemovalJob(record, jobHost(purged)) - ) - await expect(retried).rejects.toThrow(/is not the one Orca started deleting/) - await _settlePendingWorktreeRemovalsForTests() - - expect(existsSync(lockedFile)).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() - expect(purged).toEqual([]) - expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) - expect(await listedRows()).toEqual([]) - }) - it('never deletes a worktree Git registers at the path since, even on the same branch', async () => { await failStartupFinish() await setImmutable(false) @@ -399,8 +350,9 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part (reason: unknown) => reason ) await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1)) - // While it waits, a different checkout is created in the leftover's own folder. - await rm(join(worktreePath, '.git')) + // While it waits, the leftover is replaced by a different checkout. + await rm(worktreePath, { recursive: true }) + await mkdir(worktreePath) await git(['init', '-q'], worktreePath) await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') releaseSlots() @@ -412,93 +364,6 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(removeHostTree).not.toHaveBeenCalled() }) - it('checks the folder is still the leftover inside the delete slot', async () => { - await failInSession() - await setImmutable(false) - let releaseSlots = (): void => {} - const held = new Promise((resolve) => { - releaseSlots = resolve - }) - const holders = [ - runUnderWorktreeDeleteLimit(() => held), - runUnderWorktreeDeleteLimit(() => held) - ] - const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => - interruptedLocalWorktreeRemovalJob(record, jobHost([])) - ) - const settled = retried!.then( - () => undefined, - (reason: unknown) => reason - ) - await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1)) - // While it waits, the user replaces the leftover with an ordinary folder. - await rm(worktreePath, { recursive: true }) - await mkdir(worktreePath) - await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') - releaseSlots() - await Promise.all(holders) - - expect(String(await settled)).toMatch(/is not the one Orca started deleting/) - await _settlePendingWorktreeRemovalsForTests() - expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() - }) - - it('at startup, checks the folder is still the recorded checkout inside the delete slot before Git deletes it', async () => { - await setImmutable(false) - const gitFile = await readFile(join(worktreePath, '.git'), 'utf8') - let releaseSlots = (): void => {} - const held = new Promise((resolve) => { - releaseSlots = resolve - }) - const holders = [ - runUnderWorktreeDeleteLimit(() => held), - runUnderWorktreeDeleteLimit(() => held) - ] - const finished = finishAtStartup([]) - await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1)) - // While it waits, the user restores the folder from a backup, `.git` file and all, which Git - // accepts as the checkout. - await rm(worktreePath, { recursive: true }) - await mkdir(worktreePath) - await writeFile(join(worktreePath, '.git'), gitFile) - await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') - releaseSlots() - await Promise.all(holders) - - // The refusal itself, not a failed Git delete a recovery could finish by deleting the path. - expect(String(await finished)).toMatch(/^Error: The folder at .* is not the one Orca started/) - expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) - expect(await isRegistered(worktreePath)).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() - }) - - it('at startup, checks the folder is still the recorded checkout inside the delete slot when Git cannot remove it', async () => { - await setImmutable(false) - await rm(join(worktreePath, '.git')) - vi.mocked(restoreMissingWorktreeGitFile).mockResolvedValueOnce(false) - let releaseSlots = (): void => {} - const held = new Promise((resolve) => { - releaseSlots = resolve - }) - const holders = [ - runUnderWorktreeDeleteLimit(() => held), - runUnderWorktreeDeleteLimit(() => held) - ] - const finished = finishAtStartup([]) - await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1)) - // While it waits, the user replaces the checkout with an ordinary folder. - await rm(worktreePath, { recursive: true }) - await mkdir(worktreePath) - await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') - releaseSlots() - await Promise.all(holders) - - expect(String(await finished)).toMatch(/is not the one Orca started deleting/) - expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() - }) - it('at startup, still deletes the recorded checkout when its missing .git cannot be restored', async () => { await setImmutable(false) // Git deleted `.git` first and the link cannot be written back, so Git cannot remove it. diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index 009c574dc2ee..8fd770875878 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -2,8 +2,7 @@ // come from Git and disk, whatever point the earlier run reached. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { lstat, mkdir, mkdtemp, realpath, rm, unlink, writeFile } from 'node:fs/promises' -import type * as FsPromises from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -35,16 +34,8 @@ import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from '../worktree-removal-records' -import { - readCheckoutDirectoryIdentity, - type CheckoutDirectoryIdentity -} from '../worktree-checkout-identity' import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, lstat: vi.fn(actual.lstat) } -}) vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: () => ({}) })) @@ -66,7 +57,6 @@ let scratchDir = '' let recordsDir = '' let repoPath = '' let worktreePath = '' -let acceptedIdentity: CheckoutDirectoryIdentity | undefined let repo: Repo async function git(args: string[], cwd = repoPath): Promise { @@ -96,10 +86,6 @@ beforeEach(async () => { await git(['add', '-A']) await git(['commit', '-qm', 'seed']) await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature']) - // The directory as the interrupted removal accepted it, before any test's partial delete. - acceptedIdentity = await readCheckoutDirectoryIdentity(worktreePath) - // Without one, the refusals below would pass as unrecorded records, not as different folders. - expect(acceptedIdentity).toBeDefined() repo = { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '', addedAt: 0 } }) @@ -113,9 +99,7 @@ type FinishOutcome = | ({ status: 'removed' } & RemoveWorktreeResult) | { status: 'failed'; error: string } -async function finishAfterRestart( - options: { repoGone?: boolean; head?: string; keepsFailedRecord?: boolean } = {} -): Promise<{ +async function finishAfterRestart(options: { repoGone?: boolean; head?: string } = {}): Promise<{ outcome: FinishOutcome purged: string[] remember: ReturnType @@ -129,8 +113,7 @@ async function finishAfterRestart( head: options.head ?? (await git(['rev-parse', 'feature'])).trim(), deleteBranch: true, force: false, - requestedAt: 1, - checkoutIdentity: acceptedIdentity + requestedAt: 1 } await writeWorktreeRemovalRecords(recordsDir, () => [record]) await loadWorktreeRemovalRecords(recordsDir) @@ -173,9 +156,7 @@ async function finishAfterRestart( (error: unknown) => ({ status: 'failed' as const, error: String(error) }) ) await _settlePendingWorktreeRemovalsForTests() - if (!options.keepsFailedRecord) { - expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) - } + expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) expect(waitForPendingWorktreeRemoval(record.worktreeId)).toBeUndefined() // Released on every outcome, including a finish that ended before taking its own gate. beginTerminalInstall(worktreePath)() @@ -223,9 +204,6 @@ describe('finishing an interrupted worktree removal after a restart', () => { // Git before 2.48 has no relative-path worktrees. ctx.skip() } - acceptedIdentity = await readCheckoutDirectoryIdentity(worktreePath) - // Without one, the refusals below would pass as unrecorded records rather than as different folders. - expect(acceptedIdentity).toBeDefined() await unlink(join(worktreePath, '.git')) await unlink(join(worktreePath, 'seed.txt')) @@ -283,134 +261,6 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(purged).toEqual([]) }) - it('leaves an ordinary folder put at the path after Git unregistered the checkout', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - await git(['worktree', 'remove', '--force', worktreePath]) - // No `.git`, like the leftover Git leaves: only the directory's identity tells them apart. - await mkdir(worktreePath) - await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') - - const { outcome, purged } = await finishAfterRestart() - - expect(outcome).toMatchObject({ - status: 'failed', - error: expect.stringMatching(/is not the one Orca started deleting/) - }) - expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() - expect(purged).toEqual([]) - }) - - it('leaves an ordinary folder put at a still-registered checkout’s path', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(restoreMissingWorktreeGitFile).mockClear() - // Git's admin entry still names the path, so restoring `.git` would hand the folder to Git. - await rm(worktreePath, { recursive: true }) - await mkdir(worktreePath) - await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') - expect(await isRegistered(worktreePath)).toBe(true) - - const { outcome, purged } = await finishAfterRestart() - - expect(outcome).toMatchObject({ - status: 'failed', - error: expect.stringMatching(/is not the one Orca started deleting/) - }) - expect(existsSync(join(worktreePath, 'notes.txt'))).toBe(true) - expect(existsSync(join(worktreePath, '.git'))).toBe(false) - expect(restoreMissingWorktreeGitFile).not.toHaveBeenCalled() - expect(removeHostTree).not.toHaveBeenCalled() - expect(purged).toEqual([]) - }) - - it('leaves a checkout it cannot read, since nothing proves it is the one it accepted', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - await unlink(join(worktreePath, 'seed.txt')) - const { lstat: readLstat } = await vi.importActual('node:fs/promises') - const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) - vi.mocked(lstat).mockImplementation((path, options) => - path === worktreePath && options?.bigint ? Promise.reject(denied) : readLstat(path, options) - ) - - try { - const { outcome, purged } = await finishAfterRestart() - - expect(outcome).toMatchObject({ - status: 'failed', - error: expect.stringMatching(/could not read the folder/) - }) - expect(existsSync(join(worktreePath, '.git'))).toBe(true) - expect(await isRegistered(worktreePath)).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() - expect(purged).toEqual([]) - } finally { - vi.mocked(lstat).mockReset() - } - }) - - it('says it could not read an unregistered leftover whose .git it cannot read', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - await unlink(join(worktreePath, '.git')) - await git(['worktree', 'prune']) - const gitLink = join(worktreePath, '.git') - const { lstat: readLstat } = await vi.importActual('node:fs/promises') - const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) - vi.mocked(lstat).mockImplementation((path, options) => - path === gitLink ? Promise.reject(denied) : readLstat(path, options) - ) - - try { - const { outcome, purged } = await finishAfterRestart({ keepsFailedRecord: true }) - - // Not "a different checkout": deleting it again would be refused the same way. - expect(outcome).toMatchObject({ - status: 'failed', - error: expect.stringMatching(/could not read the folder/) - }) - // The row keeps that text until the folder can be read. - expect(await readWorktreeRemovalRecords(recordsDir)).toMatchObject([ - { failure: { message: expect.stringMatching(/could not read the folder/) } } - ]) - expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() - expect(purged).toEqual([]) - } finally { - vi.mocked(lstat).mockReset() - } - }) - - it('leaves an unregistered folder an older build recorded without an identity', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - acceptedIdentity = undefined - // What Git leaves when it drops the registration first: no `.git`, files still there. - await unlink(join(worktreePath, '.git')) - await git(['worktree', 'prune']) - expect(await isRegistered(worktreePath)).toBe(false) - - const { outcome, purged } = await finishAfterRestart() - - expect(outcome).toMatchObject({ - status: 'failed', - error: expect.stringMatching(/is not the one Orca started deleting/) - }) - expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() - expect(purged).toEqual([]) - }) - - it('still lets Git finish a registered checkout an older build recorded without an identity', async () => { - acceptedIdentity = undefined - await unlink(join(worktreePath, 'seed.txt')) - - const { outcome, purged } = await finishAfterRestart() - - expect(outcome).toMatchObject({ status: 'removed' }) - expect(existsSync(worktreePath)).toBe(false) - expect(await isRegistered(worktreePath)).toBe(false) - expect(removeHostTree).not.toHaveBeenCalled() - expect(purged).toEqual([`repo-1::${worktreePath}`]) - }) - it('leaves the same branch checked out again at a new head', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const head = (await git(['rev-parse', 'feature'])).trim() diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 3d8ecd467e2c..9f94feac0dc0 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -21,11 +21,8 @@ import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { assertUnregisteredRemovalLeftover, differentCheckoutAtPathError, - differentFolderAtPathError, - unregisteredRemovalLeftoverVerdict, - unreadableFolderAtPathError + isUnregisteredRemovalLeftover } from '../worktree-removal-leftover' -import { matchCheckoutDirectory } from '../worktree-checkout-identity' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { WorktreeRemovalRecord } from '../worktree-removal-records' import { @@ -131,8 +128,6 @@ async function finishInterruptedLocalWorktreeRemoval( deleteBranch: record.deleteBranch, target: { id: record.worktreeId } } - // Why the repo's current path: the record's may predate a repo move. - const leftoverRecord = { ...record, repoPath: repo.path } const worktrees = await listWorktreesStrict(repo.path, localOptions) const registered = worktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath) @@ -151,25 +146,14 @@ async function finishInterruptedLocalWorktreeRemoval( ) } const gitLink = await readCheckoutGitLink(record.worktreePath) - const directory = await matchCheckoutDirectory(record.worktreePath, record.checkoutIdentity) - if (directory === 'unreadable') { - throw unreadableFolderAtPathError(record.worktreePath) - } - // Why: the finish forces and nobody asked for it this run, so it may only take the directory the - // removal accepted. A record without an identity (an older build's) is left to Git's own checks. - if ( - directory === 'different' || - (directory === 'unrecorded' && !(deletable && gitLink === 'present')) - ) { - throw differentFolderAtPathError(record.worktreePath) - } + // Why: the finish forces, so a checkout created at this path since the quit must not be taken. // At an unregistered path, only a `.git` naming the admin entry Git removed is this checkout's // own leftover (Git drops the registration even when its delete fails partway). - const leftover = deletable ? undefined : await unregisteredRemovalLeftoverVerdict(leftoverRecord) - if (leftover === 'unreadable') { - throw unreadableFolderAtPathError(record.worktreePath) - } - if (deletable ? !isRecordedCheckout(deletable, record) : leftover !== 'leftover') { + if ( + deletable + ? !isRecordedCheckout(deletable, record) + : !(await isUnregisteredRemovalLeftover(repo.path, record.worktreePath)) + ) { throw differentCheckoutAtPathError(record.worktreePath) } // Why: Git deletes `.git` wherever it falls in directory order (early on NTFS) and refuses to @@ -179,17 +163,6 @@ async function finishInterruptedLocalWorktreeRemoval( assertWorktreeUnlockedForRemoval(deletable) gitCanRemove = await restoreMissingWorktreeGitFile(repo.path, deletable.path, localOptions) } - // Why recheck in the slot: the wait can outlast large deletes, and the path may change meanwhile. - // An older build's record (`unrecorded`) reaches only Git's delete, which validates the checkout. - const assertAcceptedDirectory = async (): Promise => { - const current = await matchCheckoutDirectory(record.worktreePath, record.checkoutIdentity) - if (current === 'unreadable') { - throw unreadableFolderAtPathError(record.worktreePath) - } - if (current === 'different' || (current === 'unrecorded' && !gitCanRemove)) { - throw differentFolderAtPathError(record.worktreePath) - } - } const gate = await args.acquireWatcherRemoval(record.worktreePath) if (args.stopPtys) { try { @@ -200,12 +173,7 @@ async function finishInterruptedLocalWorktreeRemoval( } } if (deletable && gitCanRemove) { - return finishRuntimeLocalWorktreeRemoval( - { ...finishArgs, assertCheckoutBeforeDelete: assertAcceptedDirectory }, - deletable, - gate, - args.stopSignal - ) + return finishRuntimeLocalWorktreeRemoval(finishArgs, deletable, gate, args.stopSignal) } // Unregistered, or no admin entry claims the checkout so Git cannot validate it: the leftover is // deleted in this process as a last resort, then pruned. @@ -216,9 +184,10 @@ async function finishInterruptedLocalWorktreeRemoval( repo.path, record.worktreePath, record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null, + // Why only unregistered: a registered checkout here was just proven to be the recorded one. deletable - ? assertAcceptedDirectory - : () => assertUnregisteredRemovalLeftover(leftoverRecord, localOptions), + ? async () => {} + : () => assertUnregisteredRemovalLeftover(repo.path, record.worktreePath, localOptions), localOptions ) removed = true diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index 694aade71657..a5e966b1471a 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -32,7 +32,6 @@ import { waitForPendingWorktreeRemoval } from '../worktree-background-removal' import { runSerializedWorktreeRemovalAcceptance } from '../worktree-removal-acceptance-queue' -import { readCheckoutDirectoryIdentity } from '../worktree-checkout-identity' /** Runs after the previous same-repo removal was accepted; see runSerializedWorktreeRemovalAcceptance. */ export function removeRuntimeRegisteredLocalWorktree( @@ -169,15 +168,12 @@ async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: { } // Why detached: every refusal above already ran, and Git's 20-35 s delete must finish even when the // request that asked for it times out; other views read the host's `removing` marker meanwhile. - // Why: a later retry or resume may only delete this directory, never one made at the path since. - const checkoutIdentity = await readCheckoutDirectoryIdentity(refreshed.path) void startBackgroundWorktreeRemoval({ removal: { worktreeId: args.target.id, repoId: repo.id, repoPath: repo.path, worktree: refreshed, - checkoutIdentity, deleteBranch: args.deleteBranch, force: args.force }, @@ -202,11 +198,7 @@ export type RuntimeLocalWorktreeRemovalFinishArgs = Pick< | 'closeWatchers' | 'preserveBranchHead' | 'finishRemoval' -> & { - target: { id: string } - /** For a finish nobody asked for this run: refuses in the delete slot if the checkout changed. */ - assertCheckoutBeforeDelete?: () => Promise -} +> & { target: { id: string } } /** Git's delete and everything after it; the refusals and teardown before it already ran. */ export async function finishRuntimeLocalWorktreeRemoval( @@ -219,8 +211,6 @@ export async function finishRuntimeLocalWorktreeRemoval( const canonicalPath = refreshed.path let removalResult: RemoveWorktreeResult | undefined let completed = false - const { assertCheckoutBeforeDelete } = args - let refusal: unknown try { try { removalResult = args.preserveBranchHead( @@ -228,24 +218,11 @@ export async function finishRuntimeLocalWorktreeRemoval( ...(!args.deleteBranch ? { deleteBranch: args.deleteBranch } : {}), knownRemovedWorktree: refreshed, ...localOptions, - ...(checkoutDeleteSignal ? { checkoutDeleteSignal } : {}), - ...(assertCheckoutBeforeDelete - ? { - assertCheckoutBeforeDelete: () => - assertCheckoutBeforeDelete().catch((error: unknown) => { - refusal = error - throw error - }) - } - : {}) + ...(checkoutDeleteSignal ? { checkoutDeleteSignal } : {}) }), refreshed.head ) } catch (error) { - // Why: Git deleted nothing, so no recovery below may delete the path in its place. - if (error === refusal) { - throw error - } const recovered = await recoverLocalWindowsWorktreeRemoval({ error, force: args.force, diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index 52b17d9edae9..726240fefb1f 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -15,7 +15,6 @@ import { projectPendingWorktreeRemovals, snapshotPendingWorktreeRemovals } from './worktree-removal-listing' -import type * as GitWorktree from './git/worktree' import type * as WorktreeRemovalRecords from './worktree-removal-records' import { readWorktreeRemovalRecords, @@ -23,20 +22,6 @@ import { writeWorktreeRemovalRecords } from './worktree-removal-records' -// Git still registers the checkout: a delete that fails leaves it Git's, row and all. -vi.mock('./git/worktree', async (importOriginal) => ({ - ...(await importOriginal()), - listWorktreesStrict: vi.fn(async () => [ - { - path: '/work/feature', - head: 'abc', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]) -})) - vi.mock('./worktree-removal-records', async (importOriginal) => { const actual = await importOriginal() return { @@ -51,8 +36,7 @@ const removal = { repoPath: '/work/repo', worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, deleteBranch: true, - force: false, - checkoutIdentity: undefined + force: false } const isPending = (): boolean => waitForPendingWorktreeRemoval(removal.worktreeId) !== undefined let directory = '' @@ -272,36 +256,4 @@ describe('durable worktree removal records', () => { await writeFile(worktreeRemovalRecordsFile(directory), '{not json') expect(await readWorktreeRemovalRecords(directory)).toEqual([]) }) - - it('keeps the accepted checkout identity, reading a malformed one as unrecorded', async () => { - const record = { - worktreeId: 'repo-1::/work/a', - repoId: 'repo-1', - repoPath: '/work/repo', - worktreePath: '/work/a', - branch: 'a', - head: 'abc', - deleteBranch: false, - force: true, - requestedAt: 5 - } - const checkoutIdentity = { ino: '18446744073709551615', birthtimeNs: '1' } - await writeFile( - worktreeRemovalRecordsFile(directory), - JSON.stringify({ - version: 1, - removals: [ - // A device number an earlier build of this change wrote is ignored. - { ...record, checkoutIdentity: { dev: '16777232', ...checkoutIdentity } }, - { ...record, worktreeId: 'repo-1::/work/b', checkoutIdentity: { ino: 2 } } - ] - }) - ) - - // The malformed one stays, so Git's own checks can still finish it. - expect(await readWorktreeRemovalRecords(directory)).toEqual([ - { ...record, checkoutIdentity }, - { ...record, worktreeId: 'repo-1::/work/b' } - ]) - }) }) diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts index 8f4dd906a031..1517954f25cd 100644 --- a/src/main/worktree-background-removal.test.ts +++ b/src/main/worktree-background-removal.test.ts @@ -20,8 +20,7 @@ const removal = { repoPath: '/work/repo', worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, deleteBranch: true, - force: false, - checkoutIdentity: undefined + force: false } const isPending = (hostId?: ExecutionHostId): boolean => waitForPendingWorktreeRemoval(removal.worktreeId, hostId) !== undefined diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index effc91f17619..700ecd045cae 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -6,16 +6,18 @@ import { acquireWatcherRemovalGate, type WatcherRemovalGate } from './ipc/watche import { runWorktreeChangeInvalidators } from './ipc/worktree-change-invalidators' import { parseWslPath } from './wsl' import { readWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktree-removal-records' -import type { CheckoutDirectoryIdentity } from './worktree-checkout-identity' -import { differentCheckoutAtPathError, isCheckoutRegistered } from './worktree-removal-leftover' -import { checkoutLeftByFailedRemoval } from './worktree-removal-listing' import { - endUnfinishedWorktreeRemoval, + differentCheckoutAtPathError, + isCheckoutRegistered, + isUnregisteredRemovalLeftover +} from './worktree-removal-leftover' +import { failedWorktreeRemovals, finishedWorktreeRemovals, pendingWorktreeRemovals, persistWorktreeRemovalRecords, - setWorktreeRemovalRecordsDirectory + setWorktreeRemovalRecordsDirectory, + worktreeCheckoutExists } from './worktree-removal-table' export type BackgroundWorktreeRemovalJob = { @@ -51,8 +53,7 @@ export async function loadWorktreeRemovalRecords( for (const record of await readWorktreeRemovalRecords(directory)) { if (record.failure) { // Why the repo: only its listing shows the row, so nothing else could end a removed repo's. - // Kept with its checkout gone too: that listing also ends the workspace, which loading can't. - if (hasRepo(record.repoId)) { + if (hasRepo(record.repoId) && (await worktreeCheckoutExists(record.worktreePath))) { failedWorktreeRemovals.set(record.worktreeId, record) } else { droppedFailure = true @@ -141,21 +142,16 @@ export function startBackgroundWorktreeRemoval( removal: Pick< WorktreeRemovalRecord, 'worktreeId' | 'repoId' | 'repoPath' | 'deleteBranch' | 'force' - > & { - worktree: Pick - // Required even when undefined: a record without it is never retried or resumed. - checkoutIdentity: CheckoutDirectoryIdentity | undefined - } + > & { worktree: Pick } } & BackgroundWorktreeRemovalJob ): Promise { - const { worktree, checkoutIdentity, ...accepted } = args.removal + const { worktree, ...accepted } = args.removal const record: WorktreeRemovalRecord = { ...accepted, worktreePath: worktree.path, branch: normalizeLocalBranchRef(worktree.branch), head: worktree.head, - requestedAt: Date.now(), - ...(checkoutIdentity ? { checkoutIdentity } : {}) + requestedAt: Date.now() } const settlement = addPendingRemoval(record) runBackgroundWorktreeRemoval(record, args, persistWorktreeRemovalRecords()) @@ -249,8 +245,6 @@ async function settleBackgroundWorktreeRemoval( await waitForRecordWrite(record, recorded) let settle: (settlement: RemovalSettlement) => void let failure: WorktreeRemovalRecord['failure'] - let unregistered = false - let ended: Promise | undefined try { if (stopSignal.aborted) { return @@ -267,15 +261,12 @@ async function settleBackgroundWorktreeRemoval( settle = (settlement) => settlement.reject(error) // Why: Git drops the registration even when it fails to delete the checkout, and Orca lists // workspaces from Git, so without the record the leftover would vanish with no way to retry. - const left = await checkoutLeftByFailedRemoval(record) - // Unknown is kept as failed too, so a listing decides again what the path holds. - if (left === 'leftover' || left === 'unknown') { + if (await isCheckoutLeftUnregistered(record)) { failure = { message: error instanceof Error ? error.message : String(error), failedAt: Date.now() } } - unregistered = left === 'unregistered' } finally { // A resumed job that ended before taking its own gate still holds the fence loading gave it. releaseStartupRemovalFence(record.worktreeId) @@ -290,9 +281,6 @@ async function settleBackgroundWorktreeRemoval( failedWorktreeRemovals.set(record.worktreeId, { ...record, failure }) // Git's catalog changed under a failed delete; cached scans still list the checkout. runWorktreeChangeInvalidators(record.repoId) - } else if (unregistered) { - // Before the reply, so a Delete sent after it finds no workspace left at the path. - ended = endUnfinishedWorktreeRemoval(record) } } // Why this run's own settlement: desktop IPC and runtime RPC coalesce separately, so a concurrent @@ -304,11 +292,26 @@ async function settleBackgroundWorktreeRemoval( // re-runs a finish that re-derives what is left from Git. publishSafely(job.publish) if (cleared) { - await ended await persistWorktreeRemovalRecords() } } +async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promise { + if (!(await worktreeCheckoutExists(record.worktreePath))) { + return false + } + try { + return ( + !(await isCheckoutRegistered(record)) && + (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath)) + ) + } catch (error) { + // Unknowable: the row stays however Git lists it, as before this record existed. + console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) + return false + } +} + // Why: the record should be on disk before Git deletes, so a quit resumes the delete, but a disk or // file pool stall must not hold the user's delete; past this, a crash only loses the resume. const RECORD_WRITE_WAIT_MS = 2_000 diff --git a/src/main/worktree-checkout-identity.test.ts b/src/main/worktree-checkout-identity.test.ts deleted file mode 100644 index cf5078796924..000000000000 --- a/src/main/worktree-checkout-identity.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { lstat, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' -import type * as FsPromises from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - matchCheckoutDirectory, - readCheckoutDirectoryIdentity, - type CheckoutDirectoryIdentity -} from './worktree-checkout-identity' - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, lstat: vi.fn(actual.lstat) } -}) - -let directory = '' -let checkout = '' - -beforeEach(async () => { - directory = await realpath(await mkdtemp(join(tmpdir(), 'orca-checkout-identity-'))) - checkout = join(directory, 'feature') - await mkdir(checkout) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -async function acceptedIdentity(): Promise { - const identity = await readCheckoutDirectoryIdentity(checkout) - expect(identity).toBeDefined() - return identity ?? { ino: '', birthtimeNs: '' } -} - -describe('matching the checkout directory a removal accepted', () => { - it('matches the same directory, whatever was deleted inside it', async () => { - await writeFile(join(checkout, 'seed.txt'), 'seed\n') - const identity = await acceptedIdentity() - await rm(join(checkout, 'seed.txt')) - - expect(await matchCheckoutDirectory(checkout, identity)).toBe('same') - }) - - it('does not match a directory removed and created again at the same path', async () => { - const identity = await acceptedIdentity() - await rm(checkout, { recursive: true }) - await mkdir(checkout) - await writeFile(join(checkout, 'notes.txt'), 'mine\n') - - expect(await matchCheckoutDirectory(checkout, identity)).toBe('different') - }) - - it('reports nothing at the path as absent, with or without an identity', async () => { - const identity = await acceptedIdentity() - await rm(checkout, { recursive: true }) - - expect(await matchCheckoutDirectory(checkout, identity)).toBe('absent') - expect(await matchCheckoutDirectory(checkout, undefined)).toBe('absent') - expect(await readCheckoutDirectoryIdentity(checkout)).toBeUndefined() - }) - - it('reports a path it cannot read as unreadable, neither the same nor a different folder', async () => { - const identity = await acceptedIdentity() - const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) - vi.mocked(lstat).mockRejectedValueOnce(denied).mockRejectedValueOnce(denied) - - expect(await matchCheckoutDirectory(checkout, identity)).toBe('unreadable') - expect(await readCheckoutDirectoryIdentity(checkout)).toBeUndefined() - }) - - it('reports a removal recorded without an identity as unrecorded', async () => { - expect(await matchCheckoutDirectory(checkout, undefined)).toBe('unrecorded') - }) - - it('does not match a file put at the path', async () => { - const identity = await acceptedIdentity() - await rm(checkout, { recursive: true }) - await writeFile(checkout, 'mine\n') - - expect(await matchCheckoutDirectory(checkout, identity)).toBe('different') - expect(await readCheckoutDirectoryIdentity(checkout)).toBeUndefined() - }) - - it('does not match a symlink at the path, even one to the accepted directory', async () => { - const identity = await acceptedIdentity() - const link = join(directory, 'link') - // Junction on Windows: a directory symlink needs elevation there. - await symlink(checkout, link, process.platform === 'win32' ? 'junction' : 'dir') - - // Followed, the link reaches the accepted directory, but what sits at the path is the link. - expect(await matchCheckoutDirectory(link, identity)).toBe('different') - expect(await readCheckoutDirectoryIdentity(link)).toBeUndefined() - }) - - it('never matches an identity recorded without a creation time', async () => { - const identity = await acceptedIdentity() - - // The inode alone can name a different directory where inode numbers are reused. Windows reports - // a missing creation time as a negative number. - for (const birthtimeNs of ['0', '-11644473600000000000', '']) { - expect(await matchCheckoutDirectory(checkout, { ...identity, birthtimeNs })).toBe('different') - } - }) - - it('records nothing for a directory the filesystem reports no creation time for', async () => { - for (const birthtimeNs of [0n, -11644473600000000000n]) { - const stats = await lstat(checkout, { bigint: true }) - vi.mocked(lstat).mockResolvedValueOnce(Object.assign(stats, { birthtimeNs })) - - expect(await readCheckoutDirectoryIdentity(checkout)).toBeUndefined() - } - }) - - it('records no device number, which changes when an external volume is attached again', async () => { - expect(Object.keys(await acceptedIdentity()).sort()).toEqual(['birthtimeNs', 'ino']) - }) - - it('does not match the same device and inode with a different creation time', async () => { - const identity = await acceptedIdentity() - - expect( - await matchCheckoutDirectory(checkout, { - ...identity, - birthtimeNs: (BigInt(identity.birthtimeNs) + 1n).toString() - }) - ).toBe('different') - }) -}) diff --git a/src/main/worktree-checkout-identity.ts b/src/main/worktree-checkout-identity.ts deleted file mode 100644 index f7bcf3bae399..000000000000 --- a/src/main/worktree-checkout-identity.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { lstat } from 'node:fs/promises' -import { getErrorCode } from './git/worktree-operation-options' - -/** - * The directory a removal accepted, by filesystem identity. A delete Orca finishes later (a retry - * or a restart's resume) must only take that directory: a folder created at the same path since is - * the user's, however much it looks like the leftover. Decimal strings keep 64-bit ids exact. - */ -export type CheckoutDirectoryIdentity = { - // Why no device number: it changes when an external volume is attached again. - ino: string - /** - * Positive: a filesystem without creation times reports `0` (negative on Windows), and nothing is - * recorded. Linux without statx reports the change time instead, which a partial delete moves, so - * the leftover there reads as a different folder and is left in place. - */ - birthtimeNs: string -} - -/** - * `absent`: nothing at the path. `unrecorded`: no identity was recorded, so nothing is provable. - * `unreadable`: the path could not be read just now, so it is neither the same nor different. - */ -export type CheckoutDirectoryMatch = 'same' | 'different' | 'absent' | 'unrecorded' | 'unreadable' - -async function statDirectory( - path: string -): Promise { - try { - const stats = await lstat(path, { bigint: true }) - if (!stats.isDirectory()) { - return null - } - return { ino: stats.ino.toString(), birthtimeNs: stats.birthtimeNs.toString() } - } catch (error) { - const code = getErrorCode(error) - return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'unreadable' - } -} - -// A positive decimal; a parsed record's value may be anything. -function hasCreationTime(identity: CheckoutDirectoryIdentity): boolean { - return /^[1-9]\d*$/.test(identity.birthtimeNs) -} - -/** - * Identity of the checkout directory at `path`; undefined when there is no directory there or the - * filesystem keeps no creation time, since the inode alone can name a newer directory. - */ -export async function readCheckoutDirectoryIdentity( - path: string -): Promise { - const current = await statDirectory(path) - return typeof current === 'object' && current && hasCreationTime(current) ? current : undefined -} - -/** Whether `path` still holds the directory a removal accepted. */ -export async function matchCheckoutDirectory( - path: string, - accepted: CheckoutDirectoryIdentity | undefined -): Promise { - const current = await statDirectory(path) - if (current === 'absent') { - return 'absent' - } - if (!accepted) { - return 'unrecorded' - } - if (current === 'unreadable') { - return 'unreadable' - } - if (!current || current.ino !== accepted.ino) { - return 'different' - } - // Why creation time too: Linux filesystems reuse a freed inode number for the next directory. - return hasCreationTime(accepted) && current.birthtimeNs === accepted.birthtimeNs - ? 'same' - : 'different' -} diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 7c1a7958028a..f626e7bd3272 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -2,9 +2,7 @@ // the error until Delete retries it, the checkout disappears, or its repo leaves Orca. Git is mocked // here so this runs on every platform; the real-Git version is in // runtime/runtime-failed-local-worktree-removal.test.ts. -import { existsSync } from 'node:fs' -import { lstat, mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' -import type * as FsPromises from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,16 +24,10 @@ import { snapshotPendingWorktreeRemovals, withUnregisteredRemovalCheckouts } from './worktree-removal-listing' -import { readWorktreeRemovalRecords, writeWorktreeRemovalRecords } from './worktree-removal-records' -import { readCheckoutDirectoryIdentity } from './worktree-checkout-identity' -import { failedWorktreeRemovals, setUnfinishedWorktreeRemovalHost } from './worktree-removal-table' +import { readWorktreeRemovalRecords } from './worktree-removal-records' import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-records-load' vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, lstat: vi.fn(actual.lstat) } -}) const GIT_ERROR = "error: failed to delete 'node_modules/a/LICENSE': Operation not permitted" let directory = '' @@ -63,21 +55,18 @@ beforeEach(async () => { }) afterEach(async () => { - setUnfinishedWorktreeRemovalHost(null) _resetPendingWorktreeRemovalsForTests() vi.restoreAllMocks() await rm(directory, { recursive: true, force: true }) }) -async function startFailingRemoval(): Promise { +function startFailingRemoval(): Promise { return startBackgroundWorktreeRemoval({ removal: { worktreeId, repoId: 'repo-1', repoPath: '/work/repo', worktree: { path: checkout, branch: 'refs/heads/feature', head: 'abc' }, - // As acceptance does: the directory the later retry may delete. - checkoutIdentity: await readCheckoutDirectoryIdentity(checkout), deleteBranch: true, force: true }, @@ -138,8 +127,6 @@ describe('a delete that fails after Git dropped the registration', () => { }) it('clears the record as before when Git still registers the checkout', async () => { - const endWorkspace = vi.fn() - setUnfinishedWorktreeRemovalHost(endWorkspace) vi.mocked(listWorktreesStrict).mockResolvedValue([ mainWorktree, { ...leftoverRow(), removalError: undefined } @@ -147,35 +134,6 @@ describe('a delete that fails after Git dropped the registration', () => { await failRemoval() expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) - // The workspace is still Git's, row and all. - expect(endWorkspace).not.toHaveBeenCalled() - }) - - it('drops a failed delete Git could not be asked about once Git says it still registers the checkout', async () => { - const endWorkspace = vi.fn() - setUnfinishedWorktreeRemovalHost(endWorkspace) - const checkoutRow = { ...leftoverRow(), removalError: undefined } - vi.mocked(listWorktreesStrict).mockRejectedValueOnce(new Error('git worktree list timed out')) - await failRemoval() - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) - const listRegistered = () => - withUnregisteredRemovalCheckouts('repo-1', [mainWorktree, checkoutRow]) - - // A cached scan can still list a checkout Git dropped, so the rows alone never decide. - expect(await listRegistered()).toEqual([mainWorktree, checkoutRow]) - vi.mocked(listWorktreesStrict).mockRejectedValueOnce(new Error('git worktree list timed out')) - expect(await listRegistered()).toEqual([mainWorktree, checkoutRow]) - expect(failedWorktreeRemovals.has(worktreeId)).toBe(true) - expect(endWorkspace).not.toHaveBeenCalled() - - vi.mocked(listWorktreesStrict).mockResolvedValue([mainWorktree, checkoutRow]) - expect(await listRegistered()).toEqual([mainWorktree, checkoutRow]) - expect(failedWorktreeRemovals.has(worktreeId)).toBe(false) - await vi.waitFor(async () => - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) - ) - // The workspace is still Git's. - expect(endWorkspace).not.toHaveBeenCalled() }) it('clears the record as before when the checkout is gone', async () => { @@ -229,70 +187,7 @@ describe('a delete that fails after Git dropped the registration', () => { expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() }) - it('ends the workspace when a retry finds a folder the user put at the path', async () => { - const endWorkspace = vi.fn() - setUnfinishedWorktreeRemovalHost(endWorkspace) - await failRemoval() - await rm(checkout, { recursive: true }) - await mkdir(checkout) - const retried = retryFailedWorktreeRemoval(worktreeId, 'local', () => ({ - run: async () => { - throw new Error('not the folder Orca started deleting') - }, - publish: () => {} - })) - - await expect(retried).rejects.toThrow('not the folder') - // Already by the reply, so a Delete sent after it finds nothing to delete at the path. - expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) - await _settlePendingWorktreeRemovalsForTests() - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) - }) - - it('keeps a refused retry for the next listing while Git cannot say what is at the path', async () => { - const endWorkspace = vi.fn() - setUnfinishedWorktreeRemovalHost(endWorkspace) - await failRemoval() - await rm(checkout, { recursive: true }) - await mkdir(checkout) - vi.mocked(listWorktreesStrict) - .mockResolvedValueOnce([mainWorktree]) - .mockRejectedValueOnce(new Error('git worktree list timed out')) - const retried = retryFailedWorktreeRemoval(worktreeId, 'local', () => ({ - run: async () => { - throw new Error('not the folder Orca started deleting') - }, - publish: () => {} - })) - - await expect(retried).rejects.toThrow('not the folder') - await _settlePendingWorktreeRemovalsForTests() - expect(endWorkspace).not.toHaveBeenCalled() - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toMatchObject([ - { worktreeId, failure: { message: 'not the folder Orca started deleting' } } - ]) - - expect(await listRows()).toEqual([mainWorktree]) - expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) - }) - - it('keeps the row, and its workspace, while the leftover cannot be read', async () => { - const endWorkspace = vi.fn() - setUnfinishedWorktreeRemovalHost(endWorkspace) - await failRemoval() - // A read error, like a briefly unreachable share: once to find it, once to identify it. - const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) - vi.mocked(lstat).mockRejectedValueOnce(denied).mockRejectedValueOnce(denied) - - expect(await listRows()).toEqual([mainWorktree, leftoverRow()]) - expect(endWorkspace).not.toHaveBeenCalled() - expect(await listRows()).toEqual([mainWorktree, leftoverRow()]) - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) - }) - it('keeps the row with the new error when the retry fails the same way', async () => { - const endWorkspace = vi.fn() - setUnfinishedWorktreeRemovalHost(endWorkspace) await failRemoval() const retried = retryFailedWorktreeRemoval(worktreeId, undefined, () => ({ run: async () => { @@ -307,7 +202,6 @@ describe('a delete that fails after Git dropped the registration', () => { mainWorktree, { ...leftoverRow(), removalError: 'still not permitted' } ]) - expect(endWorkspace).not.toHaveBeenCalled() }) it('does not run the recorded removal once Git registers a checkout at the path again', async () => { @@ -345,21 +239,14 @@ describe('a delete that fails after Git dropped the registration', () => { ) }) - it('ends with its workspace at the first listing after a restart, once the checkout is deleted outside Orca', async () => { - const endWorkspace = vi.fn() - setUnfinishedWorktreeRemovalHost(endWorkspace) + it('ends at startup once the checkout is deleted outside Orca', async () => { await failRemoval() _resetPendingWorktreeRemovalsForTests() await rm(checkout, { recursive: true }) await loadWorktreeRemovalRecords(join(directory, 'profile')) - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) - expect(await listRows()).toEqual([mainWorktree]) - expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) - await vi.waitFor(async () => - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) - ) + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) }) it('ends at startup once its repo is removed from Orca, leaving the files', async () => { @@ -403,97 +290,4 @@ describe('a delete that fails after Git dropped the registration', () => { expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) ) }) - - it('ends, leaving the files, once the user puts an ordinary folder at the path', async () => { - const endWorkspace = vi.fn() - setUnfinishedWorktreeRemovalHost(endWorkspace) - await failRemoval() - // No `.git`, like the leftover: only the directory's identity tells them apart. - await rm(checkout, { recursive: true }) - await mkdir(checkout) - await writeFile(join(checkout, 'notes.txt'), 'mine\n') - - expect(await listRows()).toEqual([mainWorktree]) - // With its workspace, whose creation metadata would let a later Delete take the folder. - expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) - expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() - await vi.waitFor(async () => - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) - ) - expect(existsSync(join(checkout, 'notes.txt'))).toBe(true) - }) - - it('keeps the failed delete for the next listing while Git cannot say whether it registers the path', async () => { - const endWorkspace = vi.fn() - setUnfinishedWorktreeRemovalHost(endWorkspace) - await failRemoval() - await rm(checkout, { recursive: true }) - await mkdir(checkout) - vi.mocked(listWorktreesStrict).mockRejectedValueOnce(new Error('git worktree list timed out')) - - expect(await listRows()).toEqual([mainWorktree]) - expect(endWorkspace).not.toHaveBeenCalled() - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) - - expect(await listRows()).toEqual([mainWorktree]) - expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) - await vi.waitFor(async () => - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) - ) - }) - - it('still lets a Delete retry it while a listing asks Git whether to end it', async () => { - const endWorkspace = vi.fn() - setUnfinishedWorktreeRemovalHost(endWorkspace) - await failRemoval() - await rm(checkout, { recursive: true }) - await mkdir(checkout) - let answerGit!: () => void - let askedGit!: () => void - const listingAskedGit = new Promise((resolve) => (askedGit = resolve)) - vi.mocked(listWorktreesStrict).mockImplementationOnce(() => { - askedGit() - return new Promise((resolve) => (answerGit = () => resolve([mainWorktree]))) - }) - - const listing = listRows() - await listingAskedGit - const retried = retryFailedWorktreeRemoval(worktreeId, 'local', () => ({ - run: async () => { - throw new Error('not the folder Orca started deleting') - }, - publish: () => {} - })) - await expect(retried).rejects.toThrow('not the folder') - answerGit() - - expect(await listing).toEqual([mainWorktree]) - expect(endWorkspace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ worktreeId })) - }) - - it('ends, leaving the files, for a failed delete an older build recorded without an identity', async () => { - await writeWorktreeRemovalRecords(join(directory, 'profile'), () => [ - { - worktreeId, - repoId: 'repo-1', - repoPath: '/work/repo', - worktreePath: checkout, - branch: 'feature', - head: 'abc', - deleteBranch: true, - force: true, - requestedAt: 1, - failure: { message: GIT_ERROR, failedAt: 2 } - } - ]) - await loadWorktreeRemovalRecords(join(directory, 'profile')) - - // Nothing proves the folder is still the one that delete accepted. - expect(await listRows()).toEqual([mainWorktree]) - expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined() - await vi.waitFor(async () => - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) - ) - expect(await readdir(checkout)).toEqual(['node_modules']) - }) }) diff --git a/src/main/worktree-removal-leftover.ts b/src/main/worktree-removal-leftover.ts index e23e5b799254..3e17e415ee30 100644 --- a/src/main/worktree-removal-leftover.ts +++ b/src/main/worktree-removal-leftover.ts @@ -9,45 +9,22 @@ import { canSafelyRemoveOrphanedWorktreeDirectory } from './worktree-removal-safety' import type { GitWorktreeExecOptions } from './git/worktree-operation-options' -import { matchCheckoutDirectory } from './worktree-checkout-identity' -import type { WorktreeRemovalRecord } from './worktree-removal-records' - -type RemovalLeftoverRecord = Pick< - WorktreeRemovalRecord, - 'repoPath' | 'worktreePath' | 'checkoutIdentity' -> /** * Whether a checkout path Git no longer registers still holds the removed checkout's own leftover: - * the very directory the removal accepted (or nothing at all), with no `.git` (Git deleted it first) - * or a `.git` file naming the admin entry Git removed. Anything else was put at the path since, - * unless the path could not be read, which proves neither. + * no `.git` (Git deleted it first), or a `.git` file naming the admin entry Git removed. Any other + * `.git` is a different checkout created at the path since. */ -export async function unregisteredRemovalLeftoverVerdict( - record: RemovalLeftoverRecord -): Promise<'leftover' | 'unreadable' | 'different-folder' | 'different-checkout'> { - const match = await matchCheckoutDirectory(record.worktreePath, record.checkoutIdentity) - if (match === 'absent') { - return 'leftover' - } - if (match === 'unreadable') { - return 'unreadable' - } - if (match !== 'same') { - return 'different-folder' - } +export async function isUnregisteredRemovalLeftover( + repoPath: string, + worktreePath: string +): Promise { try { - await lstat(join(record.worktreePath, '.git')) + await lstat(join(worktreePath, '.git')) } catch (error) { - return getErrorCode(error) === 'ENOENT' ? 'leftover' : 'unreadable' + return getErrorCode(error) === 'ENOENT' } - return (await canSafelyRemoveOrphanedWorktreeDirectory( - record.worktreePath, - record.repoPath, - CLIENT_REMOVAL_HOME - )) - ? 'leftover' - : 'different-checkout' + return canSafelyRemoveOrphanedWorktreeDirectory(worktreePath, repoPath, CLIENT_REMOVAL_HOME) } /** The refusal when the path no longer holds the removed checkout's own leftover. */ @@ -57,20 +34,6 @@ export function differentCheckoutAtPathError(worktreePath: string): Error { ) } -/** The refusal when the folder at the path is not the one Orca started deleting. */ -export function differentFolderAtPathError(worktreePath: string): Error { - return new Error( - `The folder at ${worktreePath} is not the one Orca started deleting, so Orca left it in place.` - ) -} - -/** The refusal when the path could not be read, so nothing proves it holds the accepted folder. */ -export function unreadableFolderAtPathError(worktreePath: string): Error { - return new Error( - `Orca could not read the folder at ${worktreePath}, so Orca left it in place. Delete it again once it can be read.` - ) -} - /** Whether Git registers a checkout at the recorded path now. */ export async function isCheckoutRegistered(record: { repoPath: string @@ -86,23 +49,16 @@ export async function isCheckoutRegistered(record: { * registers at or inside it. Run right before the delete: the path can change while it waits. */ export async function assertUnregisteredRemovalLeftover( - record: RemovalLeftoverRecord, + repoPath: string, + worktreePath: string, options: GitWorktreeExecOptions = {} ): Promise { - const { repoPath, worktreePath } = record const worktrees = await listWorktreesStrict(repoPath, options) if (worktrees.some((worktree) => areWorktreePathsEqual(worktree.path, worktreePath))) { throw differentCheckoutAtPathError(worktreePath) } assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, worktrees) - const verdict = await unregisteredRemovalLeftoverVerdict(record) - if (verdict === 'unreadable') { - throw unreadableFolderAtPathError(worktreePath) - } - if (verdict === 'different-folder') { - throw differentFolderAtPathError(worktreePath) - } - if (verdict === 'different-checkout') { + if (!(await isUnregisteredRemovalLeftover(repoPath, worktreePath))) { throw differentCheckoutAtPathError(worktreePath) } } diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts index 4d8c11ec6555..fd4dd7bfce97 100644 --- a/src/main/worktree-removal-listing.ts +++ b/src/main/worktree-removal-listing.ts @@ -1,13 +1,9 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' import type { GitWorktreeInfo } from '../shared/worktree/types' import { areWorktreePathsEqual } from './git/worktree-path-comparison' -import { - isCheckoutRegistered, - unregisteredRemovalLeftoverVerdict -} from './worktree-removal-leftover' +import { isUnregisteredRemovalLeftover } from './worktree-removal-leftover' import type { WorktreeRemovalRecord } from './worktree-removal-records' import { - endUnfinishedWorktreeRemoval, failedWorktreeRemovals, finishedWorktreeRemovals, pendingWorktreeRemovals, @@ -23,33 +19,27 @@ const NO_PENDING_REMOVALS: PendingWorktreeRemovals = new Map() /** * Git's rows for a local repo plus one for each removal this host still owns whose checkout Git no * longer lists but is still on disk: a failed delete (carrying its error) or one still finishing. - * A failed delete ends here, with its workspace, once its checkout is gone or a different folder - * took the path, and on its own once Git registers its path again. + * A failed delete ends here once its checkout is gone or a different checkout took the path. */ export async function withUnregisteredRemovalCheckouts( repoId: string, gitWorktrees: GitWorktreeInfo[] ): Promise { - const listsPath = (record: WorktreeRemovalRecord): boolean => - gitWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath)) const unlisted = [...pendingWorktreeRemovals.values(), ...failedWorktreeRemovals.values()].filter( - (record) => record.repoId === repoId && !listsPath(record) - ) - // Why: a delete that failed while Git could not be asked stays failed though Git may still - // register its checkout, and nothing else asks Git again. - const listedFailures = [...failedWorktreeRemovals.values()].filter( - (record) => record.repoId === repoId && listsPath(record) + (record) => + record.repoId === repoId && + !gitWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath)) ) - if (unlisted.length === 0 && listedFailures.length === 0) { + if (unlisted.length === 0) { return gitWorktrees } const leftovers: GitWorktreeInfo[] = [] - const notLeftovers: WorktreeRemovalRecord[] = [] + let droppedFailure = false for (const record of unlisted) { const failed = failedWorktreeRemovals.get(record.worktreeId) === record if ( (await worktreeCheckoutExists(record.worktreePath)) && - (!failed || (await keepsFailedRow(record))) + (!failed || (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath))) ) { leftovers.push({ path: record.worktreePath, @@ -60,66 +50,16 @@ export async function withUnregisteredRemovalCheckouts( ...(record.failure ? { removalError: record.failure.message } : {}) }) } else if (failed) { - notLeftovers.push(record) + failedWorktreeRemovals.delete(record.worktreeId) + droppedFailure = true } } - let dropped = false - const endings: Promise[] = [] - for (const record of [...notLeftovers, ...listedFailures]) { - // Why ask Git again: the rows may be a cached scan, and a checkout Git registers at the path - // since is a new workspace. Unknowable keeps the record for the next listing to decide. - const registered = await isCheckoutRegistered(record).catch(() => undefined) - // Read again: a Delete may have retried it meanwhile. No await from here to the end, or a - // Delete in between would find neither the record nor the end of its workspace. - if ( - registered === undefined || - (!registered && listedFailures.includes(record)) || - failedWorktreeRemovals.get(record.worktreeId) !== record - ) { - continue - } - failedWorktreeRemovals.delete(record.worktreeId) - dropped = true - if (!registered) { - endings.push(endUnfinishedWorktreeRemoval(record)) - } - } - if (dropped) { - // Why after the ends land: a crash before then keeps the record, and the next listing ends it. - void Promise.all(endings).then(() => persistWorktreeRemovalRecords()) + if (droppedFailure) { + void persistWorktreeRemovalRecords() } return leftovers.length === 0 ? gitWorktrees : [...gitWorktrees, ...leftovers] } -// Unreadable proves nothing either way: the row stays, and Delete refuses until it can be read. -async function keepsFailedRow(record: WorktreeRemovalRecord): Promise { - const verdict = await unregisteredRemovalLeftoverVerdict(record) - return verdict === 'leftover' || verdict === 'unreadable' -} - -/** - * What a failed delete left at its path: `leftover`, the accepted checkout Git no longer - * registers; `unregistered`, nothing Git registers and no leftover (a different folder, or none); - * `registered`, a checkout Git still registers; `unknown`, Git or the disk gave no answer. - */ -export async function checkoutLeftByFailedRemoval( - record: WorktreeRemovalRecord -): Promise<'leftover' | 'unregistered' | 'registered' | 'unknown'> { - try { - if (await isCheckoutRegistered(record)) { - return 'registered' - } - if (!(await worktreeCheckoutExists(record.worktreePath))) { - return 'unregistered' - } - const verdict = await unregisteredRemovalLeftoverVerdict(record) - return verdict === 'leftover' ? verdict : verdict === 'unreadable' ? 'unknown' : 'unregistered' - } catch (error) { - console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) - return 'unknown' - } -} - /** Taken before a listing reads Git; pass it to projectPendingWorktreeRemovals with the rows. */ export function snapshotPendingWorktreeRemovals(): PendingWorktreeRemovals { return pendingWorktreeRemovals.size === 0 ? NO_PENDING_REMOVALS : new Map(pendingWorktreeRemovals) diff --git a/src/main/worktree-removal-records.ts b/src/main/worktree-removal-records.ts index c1d62f80b897..b4e7dbdb7fa7 100644 --- a/src/main/worktree-removal-records.ts +++ b/src/main/worktree-removal-records.ts @@ -7,7 +7,6 @@ import { withSidecarSnapshotQueue, writeSidecarSnapshot } from './sidecar-snapshot-file' -import type { CheckoutDirectoryIdentity } from './worktree-checkout-identity' const RECORDS_FILE_NAME = 'orca-worktree-removals.json' const RECORDS_VERSION = 1 @@ -25,8 +24,6 @@ export type WorktreeRemovalRecord = { deleteBranch: boolean force: boolean requestedAt: number - /** The checkout directory when the removal was accepted; absent on records older builds wrote. */ - checkoutIdentity?: CheckoutDirectoryIdentity /** The delete failed after Git dropped the registration with the checkout still on disk. */ failure?: WorktreeRemovalFailure } @@ -54,18 +51,8 @@ function parseFailure(value: unknown): WorktreeRemovalFailure | null | undefined : null } -// Why unprovable rather than invalid: dropping the record would also drop a resume Git can finish. -function parseCheckoutIdentity(value: unknown): CheckoutDirectoryIdentity | undefined { - return isRecord(value) && typeof value.ino === 'string' && typeof value.birthtimeNs === 'string' - ? { ino: value.ino, birthtimeNs: value.birthtimeNs } - : undefined -} - function parseRecord(value: unknown): WorktreeRemovalRecord | null { const failure = isRecord(value) ? parseFailure(value.failure) : null - const checkoutIdentity = isRecord(value) - ? parseCheckoutIdentity(value.checkoutIdentity) - : undefined if ( failure === null || !isRecord(value) || @@ -91,7 +78,6 @@ function parseRecord(value: unknown): WorktreeRemovalRecord | null { deleteBranch: value.deleteBranch, force: value.force, requestedAt: value.requestedAt, - ...(checkoutIdentity ? { checkoutIdentity } : {}), ...(failure ? { failure } : {}) } } diff --git a/src/main/worktree-removal-table.ts b/src/main/worktree-removal-table.ts index 642a507e71ee..8c901bc9e37b 100644 --- a/src/main/worktree-removal-table.ts +++ b/src/main/worktree-removal-table.ts @@ -14,41 +14,11 @@ export const failedWorktreeRemovals = new Map() // Why weak: a listing that read Git before a delete finished holds the record until it replies. export const finishedWorktreeRemovals = new WeakSet() let recordsDirectory: string | null = null -let endWorkspaceOnHost: ((record: WorktreeRemovalRecord) => Promise | void) | null = null export function setWorktreeRemovalRecordsDirectory(directory: string | null): void { recordsDirectory = directory } -/** - * The host's bookkeeping for a finished delete, minus the disk: metadata, history, events. Settles - * once that bookkeeping is on disk. - */ -export function setUnfinishedWorktreeRemovalHost( - endWorkspace: ((record: WorktreeRemovalRecord) => Promise | void) | null -): void { - endWorkspaceOnHost = endWorkspace -} - -/** - * Ends the workspace of a removal that stopped without deleting its checkout while Git does not - * list the path: the folder there is not the one it accepted, or is gone. Leaves folder and branch. - * Why: the workspace's creation metadata would otherwise let a later Delete of the same id remove - * whatever folder is at the path now. Ends in this tick; settles once that is on disk, after which - * the record may be dropped from disk. - */ -export async function endUnfinishedWorktreeRemoval(record: WorktreeRemovalRecord): Promise { - // A newer removal of the workspace owns it now. - if (pendingWorktreeRemovals.has(record.worktreeId)) { - return - } - try { - await endWorkspaceOnHost?.(record) - } catch (error) { - console.warn(`[worktrees] could not end the workspace at ${record.worktreePath}`, error) - } -} - export function persistWorktreeRemovalRecords(): Promise { if (!recordsDirectory) { return Promise.resolve() From 8086fc59f630176dbfb45aa454212ef9181d2392 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:53:20 -0700 Subject: [PATCH 21/31] fix(worktrees): stop deleting Orca-created folders that have no .git and that Git no longer tracks Deleting an Orca-created workspace whose folder Git no longer tracked and that had no `.git` force- deleted the whole folder once the user confirmed force. "Orca made a workspace here once" and "there is no .git" do not prove the folder still holds that checkout: an ordinary folder the user put at the path was deleted. Such a folder now gets the existing "Refusing to delete unregistered worktree path" answer, forced or not. Once the user removes the folder, Delete takes the existing missing-path branch and drops the workspace. An orphaned worktree whose `.git` file points back at this repo is still deleted after the user confirms force, as before. --- ...worktrees-orphan-directory-cleanup.test.ts | 36 ++- ...ve-unregistered-worktree-host-home.test.ts | 3 +- .../removal/remove-unregistered-worktree.ts | 58 +---- .../removal/worktree-removal-filesystem.ts | 32 --- ...removal-and-reconciliation-part-03.spec.ts | 20 +- .../runtime-unregistered-worktree-removal.ts | 30 +-- .../runtime/runtime-worktree-filesystem.ts | 17 -- .../runtime/runtime-worktree-selection.ts | 16 -- src/main/worktree-removal-safety.test.ts | 221 +----------------- src/main/worktree-removal-safety.ts | 67 ------ 10 files changed, 29 insertions(+), 471 deletions(-) diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index 4eb08162a8a6..ce05a997d5ff 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -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 { @@ -289,13 +289,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, @@ -316,30 +316,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 }) } diff --git a/src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts b/src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts index 6f4a95d5d429..6e426794549a 100644 --- a/src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts +++ b/src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts @@ -15,8 +15,7 @@ vi.mock('./worktree-removal-ownership', () => ({ stopPtysForDestructiveWorktreeRemoval: vi.fn(async () => {}) })) vi.mock('./worktree-removal-filesystem', () => ({ - isAlreadyRemovedWorktreePath: vi.fn(async () => false), - isLocalGitRepository: vi.fn(async () => false) + isAlreadyRemovedWorktreePath: vi.fn(async () => false) })) const { removeUnregisteredWorktree } = await import('./remove-unregistered-worktree') diff --git a/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts b/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts index 7eec38790802..0915dba483db 100644 --- a/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts @@ -10,7 +10,6 @@ import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cl import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' import { assertWorktreeDoesNotContainRegisteredWorktree, - canCleanupUnregisteredOrcaLeftoverDirectory, canCleanupUnregisteredOrcaWorktreeDirectory, canSafelyRemoveOrphanedWorktreeDirectory, isDangerousWorktreeRemovalPath, @@ -37,7 +36,7 @@ import { removeWorktreeMetadataAndTransientState, stopPtysForDestructiveWorktreeRemoval } from './worktree-removal-ownership' -import { isAlreadyRemovedWorktreePath, isLocalGitRepository } from './worktree-removal-filesystem' +import { isAlreadyRemovedWorktreePath } from './worktree-removal-filesystem' export async function removeUnregisteredWorktree( context: WorktreeIpcContext, @@ -155,61 +154,6 @@ export async function removeUnregisteredWorktree( notifyWorktreesChanged(mainWindow, repoId) return {} } - if (!repo.connectionId) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - const runtimeWorktreePath = toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions) - if ( - await canCleanupUnregisteredOrcaLeftoverDirectory({ - meta: removedMeta, - worktreePath, - runtimeWorktreePath, - repo, - runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - registeredWorktrees, - statPath: access.statPath, - home: removalHome, - isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) - }) - ) { - if (!args.force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) - let removalCompleted = false - try { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - } if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { if (!args.force && !removedMeta) { // Why: without persisted metadata, require the renderer recovery path before deleting Orca-only state for an unregistered path. diff --git a/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts b/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts index 2f071f458c57..47b79cf9d405 100644 --- a/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts +++ b/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts @@ -4,7 +4,6 @@ import { toLocalWorktreeRuntimePath } from '../../../local-worktree-filesystem' import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' -import { gitExecFileAsync } from '../../../git/runner' import { isWorktreePathMissing } from '../../../worktree-removal-safety' export async function isAlreadyRemovedWorktreePath( @@ -26,34 +25,3 @@ export async function isAlreadyRemovedWorktreePath( } return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) } - -export async function isLocalGitRepository( - runtimeWorktreePath: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): Promise { - try { - await gitExecFileAsync(['status', '--short'], { - cwd: runtimeWorktreePath, - ...localWorktreeGitOptions - }) - return true - } catch (error) { - return !gitStatusErrorMeansNotRepository(error) - } -} - -export function gitStatusErrorMeansNotRepository(error: unknown): boolean { - const message = - error instanceof Error - ? error.message - : error && typeof error === 'object' && 'message' in error - ? String((error as { message: unknown }).message) - : typeof error === 'string' - ? error - : '' - const stderr = - error && typeof error === 'object' && 'stderr' in error - ? String((error as { stderr: unknown }).stderr) - : '' - return /not a git repository/i.test(`${message}\n${stderr}`) -} diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index 96abf6a97cef..b1d87571a44a 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { readFile } from 'node:fs/promises' import { resetWorktreeTestSshHostHome } from '../../worktree-removal-test-ssh-host-home' import { _settlePendingWorktreeRemovalsForTests } from '../../worktree-background-removal' @@ -586,13 +587,13 @@ describe('OrcaRuntimeService', () => { } }) - it('prompts then force-removes an Orca-created runtime unregistered leftover directory with no git marker', async () => { + it('refuses an Orca-created runtime unregistered directory with no git marker, even forced, until the user removes it', async () => { const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-leftover-')) const repoPath = join(parentDir, 'repo') const leftoverPath = join(parentDir, 'leftover') const worktreeId = `${TEST_REPO_ID}::${leftoverPath}` await mkdir(leftoverPath, { recursive: true }) - await writeFile(join(leftoverPath, 'leftover.txt'), 'kept until force\n') + await writeFile(join(leftoverPath, 'notes.txt'), 'mine\n') const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId, { orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' @@ -632,16 +633,18 @@ describe('OrcaRuntimeService', () => { try { vi.mocked(listWorktrees).mockResolvedValue([]) - await expect(runtime.removeManagedWorktree(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(runtime.removeManagedWorktree(worktreeId, { force })).rejects.toThrow( + `Refusing to delete unregistered worktree path: ${leftoverPath}` + ) + } + await expect(readFile(join(leftoverPath, 'notes.txt'), 'utf8')).resolves.toBe('mine\n') expect(removeWorktree).not.toHaveBeenCalled() expect(removeWorktreeMeta).not.toHaveBeenCalled() - await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).resolves.toEqual({}) + await rm(leftoverPath, { recursive: true, force: true }) + await expect(runtime.removeManagedWorktree(worktreeId)).resolves.toEqual({}) - await expect(lstat(leftoverPath)).rejects.toMatchObject({ code: 'ENOENT' }) expect(assertWorktreeCleanForRemoval).not.toHaveBeenCalled() expect(runHook).not.toHaveBeenCalled() expect(removeWorktree).not.toHaveBeenCalled() @@ -651,7 +654,6 @@ describe('OrcaRuntimeService', () => { expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() expect(notifier.worktreesChanged).toHaveBeenCalledWith(TEST_REPO_ID) - expect(gitSpy).toHaveBeenCalledWith(['status', '--short'], { cwd: leftoverPath }) } finally { gitSpy.mockRestore() await rm(parentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) diff --git a/src/main/runtime/runtime-unregistered-worktree-removal.ts b/src/main/runtime/runtime-unregistered-worktree-removal.ts index d499285cb12d..ed9f36c67035 100644 --- a/src/main/runtime/runtime-unregistered-worktree-removal.ts +++ b/src/main/runtime/runtime-unregistered-worktree-removal.ts @@ -18,7 +18,6 @@ import { } from '../ipc/worktree-remote' import { assertWorktreeDoesNotContainRegisteredWorktree, - canCleanupUnregisteredOrcaLeftoverDirectory, canCleanupUnregisteredOrcaWorktreeDirectory, canSafelyRemoveOrphanedWorktreeDirectory, isDangerousWorktreeRemovalPath, @@ -27,10 +26,7 @@ import { } from '../worktree-removal-safety' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' -import { - isLocalRuntimeGitRepository, - isRuntimeWorktreePathMissing -} from './runtime-worktree-filesystem' +import { isRuntimeWorktreePathMissing } from './runtime-worktree-filesystem' type RemovalGate = { finish: (removed: boolean) => Promise } @@ -93,30 +89,6 @@ export async function removeRuntimeUnregisteredWorktree(args: { args.finishRemoval() return {} } - if (route.kind === 'local') { - const access = getLocalWorktreePathAccess(args.localOptions) - const runtimeWorktreePath = toLocalWorktreeRuntimePath(target.path, args.localOptions) - if ( - await canCleanupUnregisteredOrcaLeftoverDirectory({ - meta: removedMeta, - worktreePath: target.path, - runtimeWorktreePath, - repo, - runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, args.localOptions), - registeredWorktrees, - statPath: access.statPath, - home: removalHome, - isGitRepository: (path) => isLocalRuntimeGitRepository(path, args.localOptions) - }) - ) { - if (!args.force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - await deleteUnregisteredDirectory(args) - args.finishRemoval() - return {} - } - } if (await isRuntimeWorktreePathMissing(route.hostId, target.path, args.localOptions)) { if (!args.force && !removedMeta) { throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) diff --git a/src/main/runtime/runtime-worktree-filesystem.ts b/src/main/runtime/runtime-worktree-filesystem.ts index 4f29919ca95e..e33f25f3ea7a 100644 --- a/src/main/runtime/runtime-worktree-filesystem.ts +++ b/src/main/runtime/runtime-worktree-filesystem.ts @@ -3,7 +3,6 @@ import { stat } from 'node:fs/promises' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import type { Repo } from '../../shared/repo-types' import type { Worktree } from '../../shared/worktree/types' -import { gitExecFileAsync } from '../git/runner' import { isENOENT } from '../ipc/filesystem-auth' import { getLocalWorktreePathAccess, @@ -23,7 +22,6 @@ import { mergeRuntimeFolderWorkspace } from './runtime-folder-workspace' import type { RuntimeStore } from './runtime-store-contract' -import { gitStatusErrorMeansNotRepository } from './runtime-worktree-selection' // Takes the resolved host rather than the repo: reading `repo.connectionId` answered "stat this on // the client" for a row that names its owner only as `executionHostId: 'ssh:'`, which is @@ -48,21 +46,6 @@ export async function isRuntimeWorktreePathMissing( return fsProvider ? isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) : false } -export async function isLocalRuntimeGitRepository( - runtimeWorktreePath: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): Promise { - try { - await gitExecFileAsync(['status', '--short'], { - cwd: runtimeWorktreePath, - ...localWorktreeGitOptions - }) - return true - } catch (error) { - return !gitStatusErrorMeansNotRepository(error) - } -} - function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() diff --git a/src/main/runtime/runtime-worktree-selection.ts b/src/main/runtime/runtime-worktree-selection.ts index 2a00d01ced15..ca214f4d2f62 100644 --- a/src/main/runtime/runtime-worktree-selection.ts +++ b/src/main/runtime/runtime-worktree-selection.ts @@ -10,22 +10,6 @@ export type RuntimeWorktreeRemovalTarget = { pushTarget?: GitPushTarget } -export function gitStatusErrorMeansNotRepository(error: unknown): boolean { - const message = - error instanceof Error - ? error.message - : error && typeof error === 'object' && 'message' in error - ? String((error as { message: unknown }).message) - : typeof error === 'string' - ? error - : '' - const stderr = - error && typeof error === 'object' && 'stderr' in error - ? String((error as { stderr: unknown }).stderr) - : '' - return /not a git repository/i.test(`${message}\n${stderr}`) -} - /** * Options for `removeManagedWorktree`. Named rather than positional on purpose: three of the * four are interchangeable booleans that each waive a different safety check on a destructive diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index 9aa9775de44d..e8da1d6d6da5 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { homedir } from 'node:os' + import type { GitWorktreeInfo } from '../shared/worktree/types' import { - canCleanupUnregisteredOrcaLeftoverDirectory, canSafelyRemoveOrphanedWorktreeDirectory, findRegisteredDeletableWorktree, getRegisteredDeletableWorktree, @@ -349,198 +348,6 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => { }) }) -describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { - const repo = { path: '/repos/main' } - const ownedMeta = { orcaCreatedAt: 1, orcaCreationSource: 'runtime' as const } - const baseArgs = { - meta: ownedMeta, - worktreePath: '/workspaces/orca-owned', - runtimeWorktreePath: '/workspaces/orca-owned', - repo, - runtimeRepoPath: repo.path, - registeredWorktrees: [makeGitWorktree(repo.path, true)], - home: CLIENT_REMOVAL_HOME - } - - it('rejects unregistered existing targets that are files or symlinks', async () => { - const isGitRepository = vi.fn().mockResolvedValue(false) - - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...baseArgs, - statPath: makeStatPath(['/workspaces/orca-owned']), - isGitRepository - }) - ).resolves.toBe(false) - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...baseArgs, - statPath: async (path) => { - if (path === '/workspaces/orca-owned') { - return { type: 'symlink' } - } - throw missingPath(path) - }, - isGitRepository - }) - ).resolves.toBe(false) - - expect(isGitRepository).not.toHaveBeenCalled() - }) - - it('rejects unregistered leftover directories with a .git marker', async () => { - const isGitRepository = vi.fn().mockResolvedValue(false) - - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...baseArgs, - statPath: makeStatPath(['/workspaces/orca-owned/.git'], ['/workspaces/orca-owned']), - isGitRepository - }) - ).resolves.toBe(false) - - expect(isGitRepository).not.toHaveBeenCalled() - }) - - it('rejects no-marker cleanup when only the Orca path shape matches', async () => { - const isGitRepository = vi.fn().mockResolvedValue(false) - - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...baseArgs, - meta: undefined, - statPath: makeStatPath([], ['/workspaces/orca-owned']), - isGitRepository - }) - ).resolves.toBe(false) - - expect(isGitRepository).not.toHaveBeenCalled() - }) - - it('checks dangerous paths in the original path space before runtime translation', async () => { - const homePath = homedir() - const runtimeHomePath = homePath - .replace(/^([A-Za-z]):/, (_match, drive: string) => `/mnt/${drive.toLowerCase()}`) - .replace(/\\/g, '/') - const isGitRepository = vi.fn().mockResolvedValue(false) - - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...baseArgs, - worktreePath: homePath, - runtimeWorktreePath: runtimeHomePath, - repo: { path: 'C:\\repos\\main' }, - runtimeRepoPath: '/mnt/c/repos/main', - registeredWorktrees: [makeGitWorktree('C:\\repos\\main', true)], - statPath: makeStatPath([], [runtimeHomePath]), - isGitRepository - }) - ).resolves.toBe(false) - - expect(isGitRepository).not.toHaveBeenCalled() - }) - - it('checks dangerous POSIX runtime home paths before no-marker cleanup', async () => { - const isGitRepository = vi.fn().mockResolvedValue(false) - - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...baseArgs, - worktreePath: '/home/dev', - runtimeWorktreePath: '/home/dev', - repo: { path: '/repos/main' }, - runtimeRepoPath: '/repos/main', - registeredWorktrees: [makeGitWorktree('/repos/main', true)], - statPath: makeStatPath([], ['/home/dev']), - isGitRepository - }) - ).resolves.toBe(false) - - expect(isGitRepository).not.toHaveBeenCalled() - }) - - it('rejects unregistered leftover directories that still answer git status', async () => { - const isGitRepository = vi.fn().mockResolvedValue(true) - - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...baseArgs, - statPath: makeStatPath([], ['/workspaces/orca-owned']), - isGitRepository - }) - ).resolves.toBe(false) - - expect(isGitRepository).toHaveBeenCalledWith('/workspaces/orca-owned') - }) - - it('rejects unregistered leftover directories that contain a registered child worktree', async () => { - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...baseArgs, - registeredWorktrees: [ - makeGitWorktree(repo.path, true), - makeGitWorktree('/workspaces/orca-owned/child') - ], - statPath: makeStatPath([], ['/workspaces/orca-owned']), - isGitRepository: vi.fn().mockResolvedValue(false) - }) - ).rejects.toThrow( - 'Refusing to delete worktree because it contains another registered worktree: /workspaces/orca-owned/child' - ) - }) - - it('uses runtime paths for filesystem proof and original paths for nested worktree checks', async () => { - const statPath = vi.fn(async (path: string) => { - if (path === '/mnt/c/workspaces/orca-owned') { - return { type: 'directory' } - } - throw missingPath(path) - }) - const isGitRepository = vi.fn().mockResolvedValue(false) - - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...baseArgs, - worktreePath: 'C:\\workspaces\\orca-owned', - runtimeWorktreePath: '/mnt/c/workspaces/orca-owned', - repo: { path: 'C:\\repos\\main' }, - runtimeRepoPath: '/mnt/c/repos/main', - registeredWorktrees: [ - makeGitWorktree('C:\\repos\\main', true), - makeGitWorktree('C:\\workspaces\\orca-owned-sibling') - ], - statPath, - isGitRepository - }) - ).resolves.toBe(true) - - expect(statPath).toHaveBeenCalledWith('/mnt/c/workspaces/orca-owned') - expect(statPath).toHaveBeenCalledWith('/mnt/c/workspaces/orca-owned/.git') - expect(statPath).not.toHaveBeenCalledWith('C:\\workspaces\\orca-owned') - expect(isGitRepository).toHaveBeenCalledWith('/mnt/c/workspaces/orca-owned') - }) - - it('rejects translated-runtime cleanup when original path contains a registered child', async () => { - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...baseArgs, - worktreePath: 'C:\\workspaces\\orca-owned', - runtimeWorktreePath: '/mnt/c/workspaces/orca-owned', - repo: { path: 'C:\\repos\\main' }, - runtimeRepoPath: '/mnt/c/repos/main', - registeredWorktrees: [ - makeGitWorktree('C:\\repos\\main', true), - makeGitWorktree('C:\\workspaces\\orca-owned\\child') - ], - statPath: makeStatPath([], ['/mnt/c/workspaces/orca-owned']), - isGitRepository: vi.fn().mockResolvedValue(false) - }) - ).rejects.toThrow( - 'Refusing to delete worktree because it contains another registered worktree: C:\\workspaces\\orca-owned\\child' - ) - }) -}) - describe('isDangerousWorktreeRemovalPath on an execution host', () => { // #18275: these verdicts are about the machine that runs the delete. The // client home is `homedir()` here — a macOS/Linux path that recognises none @@ -722,30 +529,4 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory on an execution host', () => ) ).resolves.toBe(true) }) - - it('refuses the leftover-directory cleanup while the host home is unanswered', async () => { - const leftoverArgs = { - meta: { orcaCreatedAt: 1, orcaCreationSource: 'ssh' as const }, - worktreePath: '/srv/homes/alice', - runtimeWorktreePath: '/srv/homes/alice', - repo: { path: '/opt/src/repo' }, - runtimeRepoPath: '/opt/src/repo', - registeredWorktrees: [], - statPath: makeStatPath([], ['/srv/homes/alice']), - isGitRepository: vi.fn().mockResolvedValue(false) - } - - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...leftoverArgs, - home: executionHostRemovalHome(null) - }) - ).resolves.toBe(false) - await expect( - canCleanupUnregisteredOrcaLeftoverDirectory({ - ...leftoverArgs, - home: executionHostRemovalHome('/srv/homes/bob') - }) - ).resolves.toBe(true) - }) }) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index 7f86c5f0e3ad..1e284fbb6163 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -1,5 +1,4 @@ import { lstat, readFile } from 'node:fs/promises' -import type { Repo } from '../shared/repo-types' import type { WorktreeMeta } from '../shared/worktree/meta-types' import type { GitWorktreeInfo } from '../shared/worktree/types' import { areWorktreePathsEqual } from './ipc/worktree-logic' @@ -181,58 +180,6 @@ export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { return false } -export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: { - meta: UnregisteredOrcaCleanupMeta | null | undefined - worktreePath: string - runtimeWorktreePath: string - repo: Pick - runtimeRepoPath: string - registeredWorktrees: readonly GitWorktreeInfo[] - statPath: StatPath - home: WorktreeRemovalHomeAuthority - isGitRepository: (runtimeWorktreePath: string) => Promise -}): Promise { - // Why: this recovery state has already lost the worktree .git marker, so the - // existing .git-file orphan proof cannot establish ownership. - // Why: without a surviving .git file, path shape alone is too weak to prove - // ownership for recursive deletion; require persisted Orca-created evidence. - if (!hasCurrentOrcaCreationProvenance(args.meta) && !hasLegacyOrcaCreationEvidence(args.meta)) { - return false - } - - // Why: same recursive delete, same rule — no home answer from the executing host, no delete. - if (!isRemovalHomeAuthorityResolved(args.home)) { - return false - } - - if ( - isDangerousWorktreeRemovalPath(args.worktreePath, args.repo.path, args.home) || - isDangerousWorktreeRemovalPath(args.runtimeWorktreePath, args.runtimeRepoPath, args.home) - ) { - return false - } - - assertWorktreeDoesNotContainRegisteredWorktree(args.worktreePath, args.registeredWorktrees) - - const targetEntry = await args.statPath(args.runtimeWorktreePath).catch(() => null) - if (!isDirectoryStat(targetEntry)) { - return false - } - - const pathOps = getPathOps(args.runtimeWorktreePath, args.runtimeRepoPath) - const gitMarkerPath = pathOps.join(args.runtimeWorktreePath, '.git') - try { - await args.statPath(gitMarkerPath) - return false - } catch (error) { - if (!isMissingPathError(error)) { - return false - } - } - - return !(await args.isGitRepository(args.runtimeWorktreePath)) -} - function hasCurrentOrcaCreationProvenance( meta: Pick | null | undefined ): boolean { @@ -288,20 +235,6 @@ function isMissingPathError(error: unknown): boolean { ) } -function isDirectoryStat(stat: unknown): boolean { - const entry = - stat && typeof stat === 'object' - ? (stat as { isDirectory?: () => boolean; isSymbolicLink?: () => boolean; type?: unknown }) - : null - if (!entry) { - return false - } - if (entry.isSymbolicLink?.() === true || entry.type === 'symlink') { - return false - } - return entry.isDirectory?.() === true || entry.type === 'directory' -} - export async function isWorktreePathMissing( worktreePath: string, statPath: (path: string) => Promise = lstat From 0f998379cd36772ea20db2374c60bd40103a4ac1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:53:20 -0700 Subject: [PATCH 22/31] fix(worktrees): delete a failed or interrupted workspace only through Git A delete that failed partway (Git dropped the registration, files left) stays listed as "Delete failed", and a delete a quit interrupted is finished at the next launch. Both later deletes removed the folder with Orca's own file removal, proven only by "Git does not list it and there is no .git". An ordinary folder the user put at that path was deleted, at startup without any click. Orca now deletes a checkout only through Git, plus the existing cleanup right after a Git delete that succeeded in the same operation: - Delete on a failed row: if Git registers a checkout at the path, the normal delete runs. If the folder is still there and Git does not register it, Delete refuses with "Git no longer tracks , so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone." and keeps the row. With the folder gone, Delete prunes Git's stale entry, deletes the branch as recorded and drops the workspace. - Startup finish of an interrupted delete: a checkout Git still registers is removed with `git worktree remove` and the user's recorded force choice, no longer always forced. If Git refuses or fails, the row shows Git's error. The step that rewrote a missing `.git` so Git could force the delete is gone. A folder Git does not register becomes a failed row and is not deleted; with the folder gone, the rest of the delete finishes. Records written by earlier daily builds still load; fields this version does not know are ignored. --- src/main/git/worktree-git-file-restore.ts | 58 ------- src/main/git/worktree-removal.ts | 14 +- src/main/ipc/worktrees-failed-removal.test.ts | 37 +++-- .../removal/execute-worktree-removal.ts | 11 +- .../orca-runtime-remove-managed-worktree.ts | 2 +- ...runtime-resolve-worktree-removal-target.ts | 11 +- .../worktree-removal-failed-retry.spec.ts | 60 ++++--- ...time-failed-local-worktree-removal.test.ts | 153 ++++++++++-------- ...interrupted-local-worktree-removal.test.ts | 148 ++++++++++------- ...time-interrupted-local-worktree-removal.ts | 74 ++------- ...orktree-background-removal-records.test.ts | 25 +++ src/main/worktree-background-removal.ts | 37 ++--- src/main/worktree-failed-removal.test.ts | 82 +++++++++- src/main/worktree-removal-leftover.ts | 98 +++++++++-- src/main/worktree-removal-listing.ts | 37 +++-- src/main/worktree-removal-table.ts | 22 --- 16 files changed, 498 insertions(+), 371 deletions(-) delete mode 100644 src/main/git/worktree-git-file-restore.ts diff --git a/src/main/git/worktree-git-file-restore.ts b/src/main/git/worktree-git-file-restore.ts deleted file mode 100644 index 616aacb2549b..000000000000 --- a/src/main/git/worktree-git-file-restore.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { readdir, readFile, writeFile } from 'node:fs/promises' -import { isAbsolute, join } from 'node:path' -import type { GitWorktreeExecOptions } from './worktree-operation-options' -import { getErrorCode } from './worktree-operation-options' -import { readRepoCommonDirFromGit } from './worktree-list-reader' -import { areWorktreePathsEqual } from './worktree-path-comparison' - -/** - * Rewrites a registered linked worktree's missing `.git` file from Git's own admin entry, the one - * whose `gitdir` points back at it, so `git worktree remove` can validate and delete the checkout. - * - * Why not `git worktree repair` (2.29+): it also re-points every other registered path's `.git`, - * including a checkout another repository now owns at a reused path. - */ -export async function restoreMissingWorktreeGitFile( - repoPath: string, - worktreePath: string, - options: GitWorktreeExecOptions = {} -): Promise { - const commonDir = await readRepoCommonDirFromGit(repoPath, options) - if (!commonDir) { - return false - } - const adminRoot = join(commonDir, 'worktrees') - let entryNames: string[] - try { - entryNames = (await readdir(adminRoot, { withFileTypes: true })) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - } catch { - return false - } - const gitFilePath = join(worktreePath, '.git') - const owners: string[] = [] - for (const name of entryNames) { - const entryDir = join(adminRoot, name) - const backlink = await readFile(join(entryDir, 'gitdir'), 'utf8').then( - (content) => content.trim(), - () => '' - ) - const resolved = backlink && (isAbsolute(backlink) ? backlink : join(entryDir, backlink)) - if (resolved && areWorktreePathsEqual(resolved, gitFilePath)) { - owners.push(entryDir) - } - } - if (owners.length !== 1) { - return false - } - // Forward slashes are what Git itself writes, and what it compares against on Windows. - const adminDir = process.platform === 'win32' ? owners[0].replaceAll('\\', '/') : owners[0] - try { - await writeFile(gitFilePath, `gitdir: ${adminDir}\n`, { flag: 'wx' }) - } catch (error) { - // Something recreated `.git` meanwhile; Git's own validation judges it. - return getErrorCode(error) === 'EEXIST' - } - return true -} diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 2f7eb8c38e00..7b5cdeb05bfe 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -112,23 +112,19 @@ 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, + assertCheckoutGone: () => Promise, options: RemoveWorktreeOptions = {} ): Promise { 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 assertCheckoutGone() await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options)).catch( (error: unknown) => console.warn(`[git] worktree prune failed in ${repoPath}`, error) ) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index f1cfa13eb803..3e6bec95ae7b 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -1,5 +1,5 @@ // 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 { @@ -12,6 +12,7 @@ import { 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' @@ -163,6 +164,7 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { afterEach(() => { _resetPendingWorktreeRemovalsForTests() vi.mocked(finishUnregisteredWorktreeRemoval).mockClear() + vi.mocked(worktreeCheckoutExists).mockResolvedValue(true) }) it('stays in the listing with the error instead of vanishing', async () => { @@ -173,13 +175,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( + "Git no longer tracks /workspace/feature-wt, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone." + ) + } + 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', @@ -196,17 +216,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() diff --git a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts index 04e25eac3667..4db036a27f13 100644 --- a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts +++ b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts @@ -37,7 +37,7 @@ import { removeUnregisteredWorktree } from './remove-unregistered-worktree' import { removeRegisteredRemoteWorktree } from './remove-registered-remote-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' /** * Refuses a repo row whose two host spellings disagree. @@ -118,9 +118,12 @@ export async function executeWorktreeRemoval( ) if ( !repo.connectionId && - retryFailedRemovalUnlessRegistered(args.worktreeId, worktreePath, registeredWorktrees, () => - retryFailedLocalWorktreeRemoval(context, args, removalHostId) - ) + (await retryFailedRemovalUnlessRegistered( + args.worktreeId, + worktreePath, + registeredWorktrees, + () => retryFailedLocalWorktreeRemoval(context, args, removalHostId) + )) ) { return { removing: true } } diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index ec9611a1d048..68707686aa6b 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -131,7 +131,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM registeredWorktrees, removalHome ) - if (this.retryFailedLocalRemoval(route, removalTarget, registeredWorktrees, options)) { + if (await this.retryFailedRemoval(route, removalTarget, registeredWorktrees, options)) { return { removing: true } } if (!registeredWorktree) { diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 71b919bbf1b9..9d005bd09076 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -36,7 +36,7 @@ import { waitForPendingWorktreeRemoval } from '../worktree-background-removal' import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' -import { retryFailedRemovalUnlessRegistered } from '../worktree-removal-table' +import { retryFailedRemovalUnlessRegistered } from '../worktree-removal-leftover' import type { GitWorktreeInfo } from '../../shared/worktree/types' export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { @@ -74,15 +74,16 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith } /** - * Delete on the leftover of a local delete that failed after Git dropped the registration, while - * Git's listing still does not register the path: runs that removal again. True when it did. + * Delete on a local delete that failed after Git dropped the registration, while Git's listing + * still does not register the path: refuses while the folder is there, else finishes that removal. + * True when it did. */ - protected retryFailedLocalRemoval( + protected async retryFailedRemoval( route: { kind: string }, target: { id: string; path: string }, registeredWorktrees: readonly GitWorktreeInfo[], options: RemoveManagedWorktreeOptions - ): boolean { + ): Promise { const store = this.store if (route.kind !== 'local' || !store) { return false diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 00cd9ad82226..12fea677e6e0 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -1,8 +1,8 @@ // A runtime Delete (paired desktop, web, mobile, CLI) on the leftover of a delete that failed after -// Git dropped the registration: the leftover is listed with its error and Delete runs the recorded -// removal again, instead of the leftover vanishing from every listing. +// Git dropped the registration: the leftover is listed with its error instead of vanishing from +// every listing; Delete refuses while the folder is there and finishes the rest once it is gone. import { existsSync } from 'node:fs' -import { realpath } from 'node:fs/promises' +import { readFile, realpath, writeFile } from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join, @@ -74,19 +74,45 @@ describe('runtime Delete on a failed delete’s leftover', () => { }) }) - it('runs the recorded removal again, answering a client that cannot wait on acceptance', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) + it('refuses while the folder Git no longer tracks is there, keeping the row', async () => { const runtime = createWorktreeRemovalRuntime() - - await expect( - runtime.removeManagedWorktree(`id:${leftoverId}`, { waitForBackgroundRemoval: false }) - ).resolves.toEqual({ removing: true }) + // The user replaced the leftover with an ordinary folder of their own. + await rm(leftover, { recursive: true, force: true }) + await mkdir(leftover) + await writeFile(join(leftover, 'notes.txt'), 'mine') + + for (const force of [false, true]) { + await expect( + runtime.removeManagedWorktree(`id:${leftoverId}`, { + force, + waitForBackgroundRemoval: false + }) + ).rejects.toThrow( + `Git no longer tracks ${leftover}, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone.` + ) + } await _settlePendingWorktreeRemovalsForTests() - // Git has no registration left for it, so Orca deletes the leftover itself. + expect(await readFile(join(leftover, 'notes.txt'), 'utf8')).toBe('mine') + expect(removeWorktree).not.toHaveBeenCalled() + const detected = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + expect(detected.worktrees.find((row) => row.id === leftoverId)).toMatchObject({ + removalError: FAILURE + }) + expect(await readWorktreeRemovalRecords(directory)).toMatchObject([ + { worktreeId: leftoverId, failure: { message: FAILURE } } + ]) + }) + + it('ends the row once the user removed the folder', async () => { + const runtime = createWorktreeRemovalRuntime() + await rm(leftover, { recursive: true, force: true }) + + const detected = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + + expect(detected.worktrees.find((row) => row.id === leftoverId)).toBeUndefined() + await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(directory)).toEqual([])) expect(removeWorktree).not.toHaveBeenCalled() - expect(existsSync(leftover)).toBe(false) - expect(await readWorktreeRemovalRecords(directory)).toEqual([]) }) it('takes the normal delete once Git registers a checkout at the path again', async () => { @@ -132,16 +158,6 @@ describe('runtime Delete on a failed delete’s leftover', () => { expect(existsSync(join(leftover, 'node_modules'))).toBe(true) expect(removeWorktree).not.toHaveBeenCalled() }) - - it('replies to a waiting client once the retry finishes', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const runtime = createWorktreeRemovalRuntime() - - await expect( - runtime.removeManagedWorktree(`id:${leftoverId}`, { waitForBackgroundRemoval: true }) - ).resolves.toEqual({}) - expect(existsSync(leftover)).toBe(false) - }) }) describe('runtime listing straight after a delete fails partway', () => { diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 2aad80e10915..dce35362af7d 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -4,7 +4,7 @@ // the file's owner; worktree-failed-removal.test.ts covers the same rules with Git mocked. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -13,15 +13,9 @@ import type { Repo } from '../../shared/repo-types' import { removeTree } from '../../shared/windows-transient-lock-removal' import type { Store } from '../persistence' import type * as HostTreeRemoval from '../host-tree-removal' -import type * as GitFileRestore from '../git/worktree-git-file-restore' -import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' import { removeHostTree } from '../host-tree-removal' import { listWorktreesStrict, removeWorktree } from '../git/worktree' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' -import { - _worktreeDeleteLimitSnapshotForTests, - runUnderWorktreeDeleteLimit -} from '../git/worktree-delete-limit' import { acquireWatcherRemovalGate, beginTerminalInstall } from '../ipc/watcher-removal-gate' import { _resetPendingWorktreeRemovalsForTests, @@ -33,6 +27,7 @@ import { waitForPendingWorktreeRemoval } from '../worktree-background-removal' import { withUnregisteredRemovalCheckouts } from '../worktree-removal-listing' +import { retryFailedRemovalUnlessRegistered } from '../worktree-removal-leftover' import { readWorktreeRemovalRecords, writeWorktreeRemovalRecords, @@ -43,10 +38,6 @@ import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local- vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: () => ({}) })) -vi.mock('../git/worktree-git-file-restore', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, restoreMissingWorktreeGitFile: vi.fn(actual.restoreMissingWorktreeGitFile) } -}) vi.mock('../host-tree-removal', async (importOriginal) => { const actual = await importOriginal() return { ...actual, removeHostTree: vi.fn(actual.removeHostTree) } @@ -104,6 +95,31 @@ function jobHost(purged: string[], stopPtys = vi.fn(async () => {})) { } satisfies Parameters[1] } +/** Delete on the row, as desktop and runtime run it: Git's listing first, then the retry. */ +async function deleteRow(purged: string[], stopPtys = vi.fn(async () => {})): Promise { + const retried = await retryFailedRemovalUnlessRegistered( + worktreeId, + worktreePath, + await listWorktreesStrict(repoPath), + () => + retryFailedWorktreeRemoval(worktreeId, 'local', (record) => + interruptedLocalWorktreeRemovalJob(record, jobHost(purged, stopPtys)) + ) + ).catch((reason: unknown) => reason) + if (retried !== true) { + return retried + } + const error = await waitForPendingWorktreeRemoval(worktreeId)?.then( + () => undefined, + (reason: unknown) => reason + ) + await _settlePendingWorktreeRemovalsForTests() + return error +} + +const refusal = (): string => + `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone.` + /** A delete a quit interrupted, finished at the next start, where Git fails on the locked file. */ function failStartupFinish(): Promise { return finishAtStartup([]) @@ -230,28 +246,53 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await listedRows()).toHaveLength(1) }) - it('Delete retries it once the file can be deleted: files, branch, metadata and record go', async () => { + it('Delete refuses while the leftover is there, and finishes branch, metadata and record once the user removed it', async () => { await failInSession() await setImmutable(false) const purged: string[] = [] const stopPtys = vi.fn(async () => {}) - const result = await retryFailedWorktreeRemoval(worktreeId, 'local', (record) => - interruptedLocalWorktreeRemovalJob(record, jobHost(purged, stopPtys)) - ) - await _settlePendingWorktreeRemovalsForTests() + expect(String(await deleteRow(purged, stopPtys))).toBe(`Error: ${refusal()}`) + expect(existsSync(lockedFile)).toBe(true) + expect(stopPtys).not.toHaveBeenCalled() + expect(purged).toEqual([]) + expect(await listedRows()).toEqual([ + { path: worktreePath, removalError: expect.stringMatching(/Operation not permitted/) } + ]) + + await rm(worktreePath, { recursive: true }) + expect(await deleteRow(purged, stopPtys)).toBeUndefined() - expect(result).toEqual({}) expect(stopPtys).toHaveBeenCalledTimes(1) - // Git has no registration left to delete by, so Orca deletes the leftover itself. - expect(removeHostTree).toHaveBeenCalledWith(worktreePath) - expect(existsSync(worktreePath)).toBe(false) + expect(removeHostTree).not.toHaveBeenCalled() expect(await git(['branch', '--list', 'feature'])).toBe('') expect(purged).toEqual([worktreeId]) expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) expect(await listedRows()).toEqual([]) }) + it('Delete deletes nothing in an ordinary folder the user put in place of the leftover', async () => { + await failInSession() + await setImmutable(false) + await rm(worktreePath, { recursive: true }) + await mkdir(worktreePath) + await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') + const purged: string[] = [] + + expect(String(await deleteRow(purged))).toBe(`Error: ${refusal()}`) + + expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n') + expect(removeHostTree).not.toHaveBeenCalled() + expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(purged).toEqual([]) + expect(await listedRows()).toEqual([ + { path: worktreePath, removalError: expect.stringMatching(/Operation not permitted/) } + ]) + expect(await readWorktreeRemovalRecords(recordsDir)).toMatchObject([ + { worktreeId, failure: { message: expect.stringMatching(/Operation not permitted/) } } + ]) + }) + it('the record ends once the checkout is deleted outside Orca', async () => { await failInSession() await setImmutable(false) @@ -270,11 +311,11 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') const purged: string[] = [] - // Delete before any listing noticed: the retry refuses and lets the record go. + // A retry already past Delete's check: the job refuses and lets the record go. const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => interruptedLocalWorktreeRemovalJob(record, jobHost(purged)) ) - await expect(retried).rejects.toThrow(/A different checkout is now at/) + await expect(retried).rejects.toThrow(refusal()) await _settlePendingWorktreeRemovalsForTests() expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) @@ -316,67 +357,49 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => interruptedLocalWorktreeRemovalJob(record, jobHost(purged)) ) - await expect(retried).rejects.toThrow(/contains another registered worktree/) + await expect(retried).rejects.toThrow(refusal()) await _settlePendingWorktreeRemovalsForTests() expect(existsSync(join(nested, 'unsaved.txt'))).toBe(true) expect(await isRegistered(nested)).toBe(true) expect(removeHostTree).not.toHaveBeenCalled() expect(purged).toEqual([]) - // The row keeps the refusal, so the user can move the nested worktree and Delete again. - expect(await listedRows()).toContainEqual({ - path: worktreePath, - removalError: expect.stringMatching(/contains another registered worktree/) - }) + expect(await listedRows()).toContainEqual({ path: worktreePath, removalError: refusal() }) }) - it('checks the leftover again inside the delete slot, right before deleting', async () => { + it('checks again after the teardown that the folder is still gone', async () => { await failInSession() await setImmutable(false) - // Both delete slots busy, as behind two large deletes. - let releaseSlots = (): void => {} - const held = new Promise((resolve) => { - releaseSlots = resolve - }) - const holders = [ - runUnderWorktreeDeleteLimit(() => held), - runUnderWorktreeDeleteLimit(() => held) - ] - const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => - interruptedLocalWorktreeRemovalJob(record, jobHost([])) - ) - const settled = retried!.then( - () => undefined, - (reason: unknown) => reason - ) - await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1)) - // While it waits, the leftover is replaced by a different checkout. await rm(worktreePath, { recursive: true }) - await mkdir(worktreePath) - await git(['init', '-q'], worktreePath) - await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') - releaseSlots() - await Promise.all(holders) + const purged: string[] = [] + // A folder appears at the path while the retry stops terminals. + const stopPtys = vi.fn(async () => { + await mkdir(worktreePath) + await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') + }) - expect(String(await settled)).toMatch(/A different checkout is now at/) - await _settlePendingWorktreeRemovalsForTests() - expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() + expect(String(await deleteRow(purged, stopPtys))).toBe(`Error: ${refusal()}`) + + expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n') + expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(purged).toEqual([]) }) - it('at startup, still deletes the recorded checkout when its missing .git cannot be restored', async () => { + it('at startup, restores no missing .git and deletes nothing: the row shows Git’s error', async () => { await setImmutable(false) - // Git deleted `.git` first and the link cannot be written back, so Git cannot remove it. await rm(join(worktreePath, '.git')) - vi.mocked(restoreMissingWorktreeGitFile).mockResolvedValueOnce(false) const purged: string[] = [] - expect(await finishAtStartup(purged)).toBeUndefined() + expect(String(await finishAtStartup(purged))).toMatch(/validation failed/) - expect(removeHostTree).toHaveBeenCalledWith(worktreePath) - expect(existsSync(worktreePath)).toBe(false) - expect(await isRegistered(worktreePath)).toBe(false) - expect(await git(['branch', '--list', 'feature'])).toBe('') - expect(purged).toEqual([worktreeId]) + expect(existsSync(join(worktreePath, '.git'))).toBe(false) + expect(existsSync(lockedFile)).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(await isRegistered(worktreePath)).toBe(true) + expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(purged).toEqual([]) + expect(await listedRows()).toEqual([ + { path: worktreePath, removalError: expect.stringMatching(/validation failed/) } + ]) }) }) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index 8fd770875878..86b543a60137 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -2,7 +2,7 @@ // come from Git and disk, whatever point the earlier run reached. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, realpath, unlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -13,8 +13,6 @@ import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { Store } from '../persistence' import type * as HostTreeRemoval from '../host-tree-removal' import { removeHostTree } from '../host-tree-removal' -import type * as WorktreeGitFileRestore from '../git/worktree-git-file-restore' -import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' import { listWorktreesStrict } from '../git/worktree' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { @@ -29,6 +27,7 @@ import { resumeInterruptedWorktreeRemovals, waitForPendingWorktreeRemoval } from '../worktree-background-removal' +import { withUnregisteredRemovalCheckouts } from '../worktree-removal-listing' import { readWorktreeRemovalRecords, writeWorktreeRemovalRecords, @@ -43,13 +42,6 @@ vi.mock('../host-tree-removal', async (importOriginal) => { const actual = await importOriginal() return { ...actual, removeHostTree: vi.fn(actual.removeHostTree) } }) -vi.mock('../git/worktree-git-file-restore', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - restoreMissingWorktreeGitFile: vi.fn(actual.restoreMissingWorktreeGitFile) - } -}) const execFileAsync = promisify(execFile) @@ -99,10 +91,13 @@ type FinishOutcome = | ({ status: 'removed' } & RemoveWorktreeResult) | { status: 'failed'; error: string } -async function finishAfterRestart(options: { repoGone?: boolean; head?: string } = {}): Promise<{ +async function finishAfterRestart( + options: { repoGone?: boolean; head?: string; force?: boolean } = {} +): Promise<{ outcome: FinishOutcome purged: string[] remember: ReturnType + records: WorktreeRemovalRecord[] }> { const record: WorktreeRemovalRecord = { worktreeId: `repo-1::${worktreePath}`, @@ -112,7 +107,7 @@ async function finishAfterRestart(options: { repoGone?: boolean; head?: string } branch: 'feature', head: options.head ?? (await git(['rev-parse', 'feature'])).trim(), deleteBranch: true, - force: false, + force: options.force ?? false, requestedAt: 1 } await writeWorktreeRemovalRecords(recordsDir, () => [record]) @@ -156,21 +151,29 @@ async function finishAfterRestart(options: { repoGone?: boolean; head?: string } (error: unknown) => ({ status: 'failed' as const, error: String(error) }) ) await _settlePendingWorktreeRemovalsForTests() - expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + const records = await readWorktreeRemovalRecords(recordsDir) expect(waitForPendingWorktreeRemoval(record.worktreeId)).toBeUndefined() // Released on every outcome, including a finish that ended before taking its own gate. beginTerminalInstall(worktreePath)() - return { outcome, purged, remember } + return { outcome, purged, remember, records } +} + +/** The non-main rows a listing shows, with any failed delete's error. */ +async function listedRows(): Promise<{ path: string; removalError?: string }[]> { + return (await withUnregisteredRemovalCheckouts(repo.id, await listWorktreesStrict(repoPath))) + .filter((row) => !row.isMainWorktree) + .map(({ path, removalError }) => ({ path, ...(removalError ? { removalError } : {}) })) } describe('finishing an interrupted worktree removal after a restart', () => { - it('finishes a checkout Git was still deleting, branch and metadata included', async () => { + it('finishes a forced delete Git was still deleting, branch and metadata included', async () => { // Git stopped partway: part of the checkout is gone, which reads as local changes. await unlink(join(worktreePath, 'seed.txt')) - const { outcome, purged } = await finishAfterRestart() + const { outcome, purged, records } = await finishAfterRestart({ force: true }) expect(outcome).toMatchObject({ status: 'removed' }) + expect(records).toEqual([]) expect( outcome && 'preservedBranch' in outcome ? outcome.preservedBranch : undefined ).toBeUndefined() @@ -180,55 +183,71 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(purged).toEqual([`repo-1::${worktreePath}`]) }) - it('lets Git finish a checkout whose .git file it had already deleted', async () => { - // Git deletes in directory order; without `.git` it refuses the checkout ("validation failed"). - await unlink(join(worktreePath, '.git')) - await unlink(join(worktreePath, 'seed.txt')) - - const { outcome, purged } = await finishAfterRestart() + it('finishes a clean checkout with the recorded non-forced choice', async () => { + const { outcome, purged, records } = await finishAfterRestart({ force: false }) expect(outcome).toMatchObject({ status: 'removed' }) + expect(records).toEqual([]) expect(existsSync(worktreePath)).toBe(false) expect(await isRegistered(worktreePath)).toBe(false) expect(await git(['branch', '--list', 'feature'])).toBe('') expect(purged).toEqual([`repo-1::${worktreePath}`]) - // Git's process deleted it, not Orca's. - expect(removeHostTree).not.toHaveBeenCalled() }) - it('lets Git finish a relative-path checkout whose .git file it had already deleted', async (ctx) => { - await git(['worktree', 'remove', worktreePath]) - try { - await git(['worktree', 'add', '-q', '--relative-paths', worktreePath, 'feature']) - } catch { - // Git before 2.48 has no relative-path worktrees. - ctx.skip() - } + it('keeps the user’s non-forced choice: Git refuses local changes and the row shows its error', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') + + const { outcome, purged, records } = await finishAfterRestart({ force: false }) + + expect(outcome).toMatchObject({ status: 'failed', error: expect.stringMatching(/--force/) }) + expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n') + expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) + expect(await isRegistered(worktreePath)).toBe(true) + expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(purged).toEqual([]) + expect(records).toMatchObject([{ failure: { message: expect.stringMatching(/--force/) } }]) + expect(await listedRows()).toEqual([ + { path: worktreePath, removalError: expect.stringMatching(/--force/) } + ]) + }) + + it('restores no missing .git: a registered checkout without it becomes a failed row, files kept', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + // Git deletes in directory order; without `.git` it refuses the checkout ("validation failed"). await unlink(join(worktreePath, '.git')) - await unlink(join(worktreePath, 'seed.txt')) - const { outcome, purged } = await finishAfterRestart() + const { outcome, purged, records } = await finishAfterRestart({ force: true }) - expect(outcome).toMatchObject({ status: 'removed' }) - expect(existsSync(worktreePath)).toBe(false) - expect(await isRegistered(worktreePath)).toBe(false) - expect(purged).toEqual([`repo-1::${worktreePath}`]) + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(join(worktreePath, '.git'))).toBe(false) + expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) expect(removeHostTree).not.toHaveBeenCalled() + expect(await isRegistered(worktreePath)).toBe(true) + expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(purged).toEqual([]) + expect(records).toHaveLength(1) + expect(await listedRows()).toEqual([ + { path: worktreePath, removalError: expect.stringMatching(/validation failed/) } + ]) }) - it('deletes the leftover itself only when Git cannot be pointed back at it', async () => { + it('deletes nothing in a folder put at the path after Git unregistered the checkout', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) - await unlink(join(worktreePath, '.git')) - vi.mocked(restoreMissingWorktreeGitFile).mockResolvedValueOnce(false) + await git(['worktree', 'remove', worktreePath]) + await mkdir(worktreePath, { recursive: true }) + await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') - const { outcome, purged } = await finishAfterRestart() + const { outcome, purged, records } = await finishAfterRestart({ force: true }) - expect(outcome).toMatchObject({ status: 'removed' }) - expect(removeHostTree).toHaveBeenCalledWith(worktreePath) - expect(existsSync(worktreePath)).toBe(false) - expect(await isRegistered(worktreePath)).toBe(false) - expect(await git(['branch', '--list', 'feature'])).toBe('') - expect(purged).toEqual([`repo-1::${worktreePath}`]) + const refusal = `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone.` + expect(outcome).toEqual({ status: 'failed', error: `Error: ${refusal}` }) + expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n') + expect(removeHostTree).not.toHaveBeenCalled() + expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(purged).toEqual([]) + expect(records).toMatchObject([{ failure: { message: refusal } }]) + expect(await listedRows()).toEqual([{ path: worktreePath, removalError: refusal }]) }) it('leaves a different checkout created at the same path since the quit', async () => { @@ -238,9 +257,10 @@ describe('finishing an interrupted worktree removal after a restart', () => { await git(['worktree', 'add', '-q', worktreePath, '-b', 'other']) await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') - const { outcome, purged } = await finishAfterRestart({ head }) + const { outcome, purged, records } = await finishAfterRestart({ head, force: true }) expect(outcome).toMatchObject({ status: 'failed' }) + expect(records).toEqual([]) expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) expect(await isRegistered(worktreePath)).toBe(true) expect(purged).toEqual([]) @@ -253,9 +273,11 @@ describe('finishing an interrupted worktree removal after a restart', () => { await git(['init', '-q'], worktreePath) await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') - const { outcome, purged } = await finishAfterRestart() + const { outcome, purged, records } = await finishAfterRestart({ force: true }) expect(outcome).toMatchObject({ status: 'failed' }) + // Not the removed checkout's leftover, so no row either, as a listing would decide. + expect(records).toEqual([]) expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) expect(removeHostTree).not.toHaveBeenCalled() expect(purged).toEqual([]) @@ -270,9 +292,10 @@ describe('finishing an interrupted worktree removal after a restart', () => { await git(['add', '-A'], worktreePath) await git(['commit', '-qm', 'work'], worktreePath) - const { outcome, purged } = await finishAfterRestart({ head }) + const { outcome, purged, records } = await finishAfterRestart({ head, force: true }) expect(outcome).toMatchObject({ status: 'failed' }) + expect(records).toEqual([]) expect(existsSync(join(worktreePath, 'work.txt'))).toBe(true) expect(purged).toEqual([]) }) @@ -282,19 +305,21 @@ describe('finishing an interrupted worktree removal after a restart', () => { await git(['worktree', 'lock', worktreePath]) await unlink(join(worktreePath, '.git')) - const { outcome, purged } = await finishAfterRestart() + const { outcome, purged, records } = await finishAfterRestart({ force: true }) expect(outcome).toMatchObject({ status: 'failed' }) expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) expect(purged).toEqual([]) + expect(records).toHaveLength(1) }) it('deletes the branch when Git finished the checkout but the quit came before the branch', async () => { await git(['worktree', 'remove', worktreePath]) - const { outcome, purged } = await finishAfterRestart() + const { outcome, purged, records } = await finishAfterRestart() expect(outcome).toMatchObject({ status: 'removed' }) + expect(records).toEqual([]) expect(await git(['branch', '--list', 'feature'])).toBe('') expect(purged).toEqual([`repo-1::${worktreePath}`]) }) @@ -306,8 +331,9 @@ describe('finishing an interrupted worktree removal after a restart', () => { const head = (await git(['rev-parse', 'feature'])).trim() await git(['worktree', 'remove', worktreePath]) - const { outcome, remember } = await finishAfterRestart() + const { outcome, remember, records } = await finishAfterRestart() + expect(records).toEqual([]) expect(outcome).toMatchObject({ status: 'removed', preservedBranch: { branchName: 'feature', head } @@ -327,8 +353,9 @@ describe('finishing an interrupted worktree removal after a restart', () => { await git(['worktree', 'remove', worktreePath]) await git(['branch', '-d', 'feature']) - const { outcome, purged } = await finishAfterRestart({ head }) + const { outcome, purged, records } = await finishAfterRestart({ head }) + expect(records).toEqual([]) expect(outcome).toMatchObject({ status: 'removed' }) expect( outcome && 'preservedBranch' in outcome ? outcome.preservedBranch : undefined @@ -336,24 +363,29 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(purged).toEqual([`repo-1::${worktreePath}`]) }) - it('returns the row live when the finish fails, instead of retrying unseen', async () => { + it('returns the row with the error when the finish fails, instead of retrying unseen', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) // Another Git client locked it while Orca was not running. await git(['worktree', 'lock', worktreePath]) - const { outcome, purged } = await finishAfterRestart() + const { outcome, purged, records } = await finishAfterRestart({ force: true }) expect(outcome).toMatchObject({ status: 'failed' }) expect(existsSync(worktreePath)).toBe(true) expect(await isRegistered(worktreePath)).toBe(true) expect(purged).toEqual([]) + expect(records).toMatchObject([{ failure: { message: expect.stringMatching(/locked/) } }]) + expect(await listedRows()).toEqual([ + { path: worktreePath, removalError: expect.stringMatching(/locked/) } + ]) }) it('drops a record whose repo Orca no longer has', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { outcome, purged } = await finishAfterRestart({ repoGone: true }) + const { outcome, purged, records } = await finishAfterRestart({ repoGone: true }) + expect(records).toEqual([]) expect(outcome).toMatchObject({ status: 'removed' }) expect(purged).toEqual([]) expect(existsSync(worktreePath)).toBe(true) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 9f94feac0dc0..fe5740f364f0 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -1,8 +1,4 @@ -import { lstat } from 'node:fs/promises' -import { join } from 'node:path' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { GitWorktreeInfo } from '../../shared/worktree/types' -import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { Store } from '../persistence' import { @@ -13,15 +9,13 @@ import { resolveWorktreeRemovalMetadata } from '../worktree-removal-repo-owner' import type { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' import { listWorktreesStrict } from '../git/worktree' import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' -import { getErrorCode, normalizeLocalBranchRef } from '../git/worktree-operation-options' -import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { - assertUnregisteredRemovalLeftover, + assertUnregisteredCheckoutGone, differentCheckoutAtPathError, - isUnregisteredRemovalLeftover + isRecordedCheckout } from '../worktree-removal-leftover' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { WorktreeRemovalRecord } from '../worktree-removal-records' @@ -36,7 +30,7 @@ type InterruptedWorktreeRemovalHost = { acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise }> closeWatchers: (path: string) => Promise preservedBranchCleanup: Pick - /** A retry's teardown: terminals may have opened in the leftover since the failed delete. */ + /** A retry's teardown: terminals opened in the folder before the user removed it. */ stopPtys?: () => Promise /** Drops the worktree's host state (metadata, history, caches), as every removal path does. */ purge: (record: WorktreeRemovalRecord) => void @@ -102,8 +96,10 @@ type InterruptedLocalWorktreeRemovalArgs = Pick< } /** - * Finishes a removal a quit or crash interrupted. What is left comes from Git and disk, not the - * record, so a delete Git already finished (fully or partly) completes the same way. + * Finishes a removal a quit or crash interrupted, or a failed one whose folder the user removed. + * What is left comes from Git and disk, not the record: a checkout Git registers is deleted by Git + * with the recorded choices; a folder Git no longer registers is refused, never deleted; with the + * folder gone, the rest of the delete finishes. */ async function finishInterruptedLocalWorktreeRemoval( args: InterruptedLocalWorktreeRemovalArgs @@ -123,8 +119,7 @@ async function finishInterruptedLocalWorktreeRemoval( finishRemoval: args.finishRemoval, repo, localOptions, - // Why force: Git already deleted part of the checkout, which reads as local changes. - force: true, + force: record.force, deleteBranch: record.deleteBranch, target: { id: record.worktreeId } } @@ -145,23 +140,12 @@ async function finishInterruptedLocalWorktreeRemoval( `Worktree registration changed during deletion: ${record.worktreePath}. Retry deletion.` ) } - const gitLink = await readCheckoutGitLink(record.worktreePath) - // Why: the finish forces, so a checkout created at this path since the quit must not be taken. - // At an unregistered path, only a `.git` naming the admin entry Git removed is this checkout's - // own leftover (Git drops the registration even when its delete fails partway). - if ( - deletable - ? !isRecordedCheckout(deletable, record) - : !(await isUnregisteredRemovalLeftover(repo.path, record.worktreePath)) - ) { + if (deletable && !isRecordedCheckout(deletable, record)) { throw differentCheckoutAtPathError(record.worktreePath) } - // Why: Git deletes `.git` wherever it falls in directory order (early on NTFS) and refuses to - // remove a checkout left without it; restoring the link from Git's admin entry lets Git finish. - let gitCanRemove = !!deletable - if (deletable && gitLink === 'missing') { - assertWorktreeUnlockedForRemoval(deletable) - gitCanRemove = await restoreMissingWorktreeGitFile(repo.path, deletable.path, localOptions) + // Before the teardown, so a refused folder keeps its terminals and watchers. + if (!deletable) { + await assertUnregisteredCheckoutGone(record.worktreePath) } const gate = await args.acquireWatcherRemoval(record.worktreePath) if (args.stopPtys) { @@ -172,11 +156,9 @@ async function finishInterruptedLocalWorktreeRemoval( throw error } } - if (deletable && gitCanRemove) { + if (deletable) { return finishRuntimeLocalWorktreeRemoval(finishArgs, deletable, gate, args.stopSignal) } - // Unregistered, or no admin entry claims the checkout so Git cannot validate it: the leftover is - // deleted in this process as a last resort, then pruned. let result: RemoveWorktreeResult let removed = false try { @@ -184,10 +166,8 @@ async function finishInterruptedLocalWorktreeRemoval( repo.path, record.worktreePath, record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null, - // Why only unregistered: a registered checkout here was just proven to be the recorded one. - deletable - ? async () => {} - : () => assertUnregisteredRemovalLeftover(repo.path, record.worktreePath, localOptions), + // Again after the teardown's waits: a folder may have appeared at the path meanwhile. + () => assertUnregisteredCheckoutGone(record.worktreePath), localOptions ) removed = true @@ -198,27 +178,3 @@ async function finishInterruptedLocalWorktreeRemoval( args.finishRemoval(result, true, record.head) return result } - -function isRecordedCheckout(worktree: GitWorktreeInfo, record: WorktreeRemovalRecord): boolean { - return ( - normalizeLocalBranchRef(worktree.branch) === record.branch && - (!record.head || worktree.head === record.head) - ) -} - -/** `missing`: the checkout directory is there without its `.git`; unreadable counts as present. */ -async function readCheckoutGitLink( - worktreePath: string -): Promise<'no-checkout' | 'missing' | 'present'> { - try { - await lstat(worktreePath) - } catch { - return 'no-checkout' - } - try { - await lstat(join(worktreePath, '.git')) - return 'present' - } catch (error) { - return getErrorCode(error) === 'ENOENT' ? 'missing' : 'present' - } -} diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts index 726240fefb1f..fc4c0ddaf415 100644 --- a/src/main/worktree-background-removal-records.test.ts +++ b/src/main/worktree-background-removal-records.test.ts @@ -256,4 +256,29 @@ describe('durable worktree removal records', () => { await writeFile(worktreeRemovalRecordsFile(directory), '{not json') expect(await readWorktreeRemovalRecords(directory)).toEqual([]) }) + + it('reads a record that carries a field this version does not know, ignoring the field', async () => { + const record = { + worktreeId: 'repo-1::/work/a', + repoId: 'repo-1', + repoPath: '/work/repo', + worktreePath: '/work/a', + branch: 'feature', + head: 'abc', + deleteBranch: true, + force: false, + requestedAt: 5, + failure: { message: 'Operation not permitted', failedAt: 6 } + } + // Daily builds before this one may have written a checkout identity with the record. + await writeFile( + worktreeRemovalRecordsFile(directory), + JSON.stringify({ + version: 1, + removals: [{ ...record, checkoutIdentity: { dev: 1, ino: 2, birthtimeMs: 3 } }] + }) + ) + + expect(await readWorktreeRemovalRecords(directory)).toEqual([record]) + }) }) diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index 700ecd045cae..fd1742063d11 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -9,7 +9,7 @@ import { readWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktr import { differentCheckoutAtPathError, isCheckoutRegistered, - isUnregisteredRemovalLeftover + keepsFailedRemovalRow } from './worktree-removal-leftover' import { failedWorktreeRemovals, @@ -160,9 +160,9 @@ export function startBackgroundWorktreeRemoval( } /** - * Delete on a failed delete's leftover that Git's current listing still does not register: runs the - * recorded removal again, with the choices the user made the first time, or joins the one another - * request started while this one listed Git. Undefined when neither. + * Delete on a failed delete whose folder is gone and that Git's current listing still does not + * register: finishes the recorded removal with the choices the user made the first time, or joins + * the one another request started while this one listed Git. Undefined when neither. */ export function retryFailedWorktreeRemoval( worktreeId: string, @@ -201,7 +201,7 @@ export function resumeInterruptedWorktreeRemovals( ): void { for (const record of pendingWorktreeRemovals.values()) { if (!jobsByWorktreeId.has(record.worktreeId)) { - runBackgroundWorktreeRemoval(record, jobFor(record), Promise.resolve()) + runBackgroundWorktreeRemoval(record, jobFor(record), Promise.resolve(), true) } } } @@ -216,7 +216,8 @@ export function stopBackgroundWorktreeRemovals(): void { function runBackgroundWorktreeRemoval( record: WorktreeRemovalRecord, job: BackgroundWorktreeRemovalJob, - recorded: Promise + recorded: Promise, + resumed = false ): void { const controller = new AbortController() stopControllers.add(controller) @@ -225,7 +226,8 @@ function runBackgroundWorktreeRemoval( settlementsByWorktreeId.get(record.worktreeId), job, recorded, - controller.signal + controller.signal, + resumed ).finally(() => { stopControllers.delete(controller) if (jobsByWorktreeId.get(record.worktreeId) === settled) { @@ -240,7 +242,8 @@ async function settleBackgroundWorktreeRemoval( settlement: RemovalSettlement | undefined, job: BackgroundWorktreeRemovalJob, recorded: Promise, - stopSignal: AbortSignal + stopSignal: AbortSignal, + resumed: boolean ): Promise { await waitForRecordWrite(record, recorded) let settle: (settlement: RemovalSettlement) => void @@ -261,7 +264,7 @@ async function settleBackgroundWorktreeRemoval( settle = (settlement) => settlement.reject(error) // Why: Git drops the registration even when it fails to delete the checkout, and Orca lists // workspaces from Git, so without the record the leftover would vanish with no way to retry. - if (await isCheckoutLeftUnregistered(record)) { + if (await keepsFailedRemovalRow(record, resumed)) { failure = { message: error instanceof Error ? error.message : String(error), failedAt: Date.now() @@ -296,22 +299,6 @@ async function settleBackgroundWorktreeRemoval( } } -async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promise { - if (!(await worktreeCheckoutExists(record.worktreePath))) { - return false - } - try { - return ( - !(await isCheckoutRegistered(record)) && - (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath)) - ) - } catch (error) { - // Unknowable: the row stays however Git lists it, as before this record existed. - console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) - return false - } -} - // Why: the record should be on disk before Git deletes, so a quit resumes the delete, but a disk or // file pool stall must not hold the user's delete; past this, a crash only loses the resume. const RECORD_WRITE_WAIT_MS = 2_000 diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index f626e7bd3272..5d1795c38839 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -1,5 +1,6 @@ // A delete that fails after Git dropped the checkout's registration: the leftover stays listed with -// the error until Delete retries it, the checkout disappears, or its repo leaves Orca. Git is mocked +// the error until the checkout disappears, Git registers a checkout there again, or its repo leaves +// Orca; Delete refuses while the folder is on disk. Git is mocked // here so this runs on every platform; the real-Git version is in // runtime/runtime-failed-local-worktree-removal.test.ts. import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' @@ -24,7 +25,8 @@ import { snapshotPendingWorktreeRemovals, withUnregisteredRemovalCheckouts } from './worktree-removal-listing' -import { readWorktreeRemovalRecords } from './worktree-removal-records' +import { retryFailedRemovalUnlessRegistered } from './worktree-removal-leftover' +import { readWorktreeRemovalRecords, writeWorktreeRemovalRecords } from './worktree-removal-records' import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-records-load' vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) @@ -290,4 +292,80 @@ describe('a delete that fails after Git dropped the registration', () => { expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) ) }) + + it('Delete refuses while the folder is on disk: nothing runs, the record and row stay', async () => { + await failRemoval() + const retry = vi.fn() + + await expect( + retryFailedRemovalUnlessRegistered(worktreeId, checkout, [mainWorktree], retry) + ).rejects.toThrow( + `Git no longer tracks ${checkout}, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone.` + ) + + expect(retry).not.toHaveBeenCalled() + expect(await readdir(checkout)).toEqual(['node_modules']) + expect(await listRows()).toEqual([mainWorktree, leftoverRow()]) + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) + }) + + it('Delete finishes the recorded removal once the user removed the folder', async () => { + await failRemoval() + await rm(checkout, { recursive: true }) + const retry = vi.fn(() => Promise.resolve({})) + + await expect( + retryFailedRemovalUnlessRegistered(worktreeId, checkout, [mainWorktree], retry) + ).resolves.toBe(true) + + expect(retry).toHaveBeenCalledTimes(1) + }) + + it('a delete resumed at startup that fails on a checkout Git still registers shows the error on Git’s row', async () => { + const registered = { ...leftoverRow(), removalError: undefined } + vi.mocked(listWorktreesStrict).mockResolvedValue([mainWorktree, registered]) + const record = { + worktreeId, + repoId: 'repo-1', + repoPath: '/work/repo', + worktreePath: checkout, + branch: 'feature', + head: 'abc', + deleteBranch: true, + force: false, + requestedAt: 1 + } + _resetPendingWorktreeRemovalsForTests() + await writeWorktreeRemovalRecords(join(directory, 'profile'), () => [record]) + await loadWorktreeRemovalRecords(join(directory, 'profile')) + + resumeInterruptedWorktreeRemovals(() => ({ + run: async () => { + throw new Error('contains modified or untracked files, use --force to delete it') + }, + publish: () => {} + })) + await _settlePendingWorktreeRemovalsForTests() + + expect(await withUnregisteredRemovalCheckouts('repo-1', [mainWorktree, registered])).toEqual([ + mainWorktree, + { ...registered, removalError: expect.stringMatching(/--force/) } + ]) + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toMatchObject([ + { worktreeId, failure: { message: expect.stringMatching(/--force/) } } + ]) + }) + + it('ends at the next listing once Git lists a different checkout at the path', async () => { + await failRemoval() + const other = { ...leftoverRow(), branch: 'refs/heads/other', removalError: undefined } + + expect(await withUnregisteredRemovalCheckouts('repo-1', [mainWorktree, other])).toEqual([ + mainWorktree, + other + ]) + await vi.waitFor(async () => + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + ) + }) }) diff --git a/src/main/worktree-removal-leftover.ts b/src/main/worktree-removal-leftover.ts index 3e17e415ee30..dfbb0127cc9f 100644 --- a/src/main/worktree-removal-leftover.ts +++ b/src/main/worktree-removal-leftover.ts @@ -1,14 +1,18 @@ import { lstat } from 'node:fs/promises' import { join } from 'node:path' +import type { RemoveWorktreeResult } from '../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' import { listWorktreesStrict } from './git/worktree' -import { getErrorCode } from './git/worktree-operation-options' +import { getErrorCode, normalizeLocalBranchRef } from './git/worktree-operation-options' import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { CLIENT_REMOVAL_HOME } from './worktree-removal-home-guard' +import type { WorktreeRemovalRecord } from './worktree-removal-records' import { - assertWorktreeDoesNotContainRegisteredWorktree, - canSafelyRemoveOrphanedWorktreeDirectory -} from './worktree-removal-safety' -import type { GitWorktreeExecOptions } from './git/worktree-operation-options' + failedWorktreeRemovals, + persistWorktreeRemovalRecords, + worktreeCheckoutExists +} from './worktree-removal-table' +import { canSafelyRemoveOrphanedWorktreeDirectory } from './worktree-removal-safety' /** * Whether a checkout path Git no longer registers still holds the removed checkout's own leftover: @@ -34,6 +38,17 @@ export function differentCheckoutAtPathError(worktreePath: string): Error { ) } +/** Whether a checkout Git registers is the one the removal was accepted for. */ +export function isRecordedCheckout( + worktree: Pick, + record: { branch: string; head: string } +): boolean { + return ( + normalizeLocalBranchRef(worktree.branch) === record.branch && + (!record.head || worktree.head === record.head) + ) +} + /** Whether Git registers a checkout at the recorded path now. */ export async function isCheckoutRegistered(record: { repoPath: string @@ -45,20 +60,69 @@ export async function isCheckoutRegistered(record: { } /** - * Refuses unless the path still holds the removed checkout's own leftover, with no worktree Git - * registers at or inside it. Run right before the delete: the path can change while it waits. + * Whether a failed delete stays listed with its error: its leftover Git no longer registers, or, + * for a delete resumed at startup, the recorded checkout Git still registers, since nobody waits on + * that delete's reply to see the error. */ -export async function assertUnregisteredRemovalLeftover( - repoPath: string, +export async function keepsFailedRemovalRow( + record: WorktreeRemovalRecord, + resumed: boolean +): Promise { + if (!(await worktreeCheckoutExists(record.worktreePath))) { + return false + } + try { + const registered = (await listWorktreesStrict(record.repoPath)).find((worktree) => + areWorktreePathsEqual(worktree.path, record.worktreePath) + ) + return registered + ? resumed && isRecordedCheckout(registered, record) + : await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath) + } catch (error) { + // Unknowable: the row stays however Git lists it, as before this record existed. + console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) + return false + } +} + +/** + * The refusal for a folder Git no longer registers: Orca deletes a checkout only through Git, so + * the user removes the folder, and the workspace ends once it is gone. + */ +export function unregisteredFolderRefusal(worktreePath: string): Error { + return new Error( + `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone.` + ) +} + +/** Refuses while anything is at a checkout path Git no longer registers. */ +export async function assertUnregisteredCheckoutGone(worktreePath: string): Promise { + if (await worktreeCheckoutExists(worktreePath)) { + throw unregisteredFolderRefusal(worktreePath) + } +} + +/** + * Delete's choice for a workspace whose earlier delete failed, from Git's listing taken now: a + * checkout Git registers at the path again is the normal delete's, so the failed record is dropped; + * a folder Git does not register is refused, the record kept; with the folder gone, `retry` runs + * or joins the recorded removal to finish the rest. True then. + */ +export async function retryFailedRemovalUnlessRegistered( + worktreeId: string, worktreePath: string, - options: GitWorktreeExecOptions = {} -): Promise { - const worktrees = await listWorktreesStrict(repoPath, options) - if (worktrees.some((worktree) => areWorktreePathsEqual(worktree.path, worktreePath))) { - throw differentCheckoutAtPathError(worktreePath) + registeredWorktrees: readonly Pick[], + retry: () => Promise | undefined +): Promise { + if (registeredWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, worktreePath))) { + if (failedWorktreeRemovals.delete(worktreeId)) { + void persistWorktreeRemovalRecords() + } + return false } - assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, worktrees) - if (!(await isUnregisteredRemovalLeftover(repoPath, worktreePath))) { - throw differentCheckoutAtPathError(worktreePath) + const failed = failedWorktreeRemovals.get(worktreeId) + if (failed) { + await assertUnregisteredCheckoutGone(failed.worktreePath) } + return retry() !== undefined } diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts index fd4dd7bfce97..7e2bb61b2c56 100644 --- a/src/main/worktree-removal-listing.ts +++ b/src/main/worktree-removal-listing.ts @@ -1,7 +1,7 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' import type { GitWorktreeInfo } from '../shared/worktree/types' import { areWorktreePathsEqual } from './git/worktree-path-comparison' -import { isUnregisteredRemovalLeftover } from './worktree-removal-leftover' +import { isRecordedCheckout, isUnregisteredRemovalLeftover } from './worktree-removal-leftover' import type { WorktreeRemovalRecord } from './worktree-removal-records' import { failedWorktreeRemovals, @@ -19,26 +19,43 @@ const NO_PENDING_REMOVALS: PendingWorktreeRemovals = new Map() /** * Git's rows for a local repo plus one for each removal this host still owns whose checkout Git no * longer lists but is still on disk: a failed delete (carrying its error) or one still finishing. - * A failed delete ends here once its checkout is gone or a different checkout took the path. + * A failed delete of a checkout Git still lists carries its error on Git's row. A failed delete ends + * here once its checkout is gone or a different checkout took the path. */ export async function withUnregisteredRemovalCheckouts( repoId: string, gitWorktrees: GitWorktreeInfo[] ): Promise { - const unlisted = [...pendingWorktreeRemovals.values(), ...failedWorktreeRemovals.values()].filter( - (record) => - record.repoId === repoId && - !gitWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath)) + const records = [...pendingWorktreeRemovals.values(), ...failedWorktreeRemovals.values()].filter( + (record) => record.repoId === repoId ) - if (unlisted.length === 0) { + if (records.length === 0) { return gitWorktrees } + let rows = gitWorktrees const leftovers: GitWorktreeInfo[] = [] let droppedFailure = false - for (const record of unlisted) { + for (const record of records) { const failed = failedWorktreeRemovals.get(record.worktreeId) === record + const listed = gitWorktrees.find((worktree) => + areWorktreePathsEqual(worktree.path, record.worktreePath) + ) + if (listed && !failed) { + continue + } + const exists = await worktreeCheckoutExists(record.worktreePath) + if (listed) { + if (exists && record.failure && isRecordedCheckout(listed, record)) { + const removalError = record.failure.message + rows = rows.map((row) => (row === listed ? { ...row, removalError } : row)) + } else { + failedWorktreeRemovals.delete(record.worktreeId) + droppedFailure = true + } + continue + } if ( - (await worktreeCheckoutExists(record.worktreePath)) && + exists && (!failed || (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath))) ) { leftovers.push({ @@ -57,7 +74,7 @@ export async function withUnregisteredRemovalCheckouts( if (droppedFailure) { void persistWorktreeRemovalRecords() } - return leftovers.length === 0 ? gitWorktrees : [...gitWorktrees, ...leftovers] + return leftovers.length === 0 ? rows : [...rows, ...leftovers] } /** Taken before a listing reads Git; pass it to projectPendingWorktreeRemovals with the rows. */ diff --git a/src/main/worktree-removal-table.ts b/src/main/worktree-removal-table.ts index 8c901bc9e37b..2c3be4e9055e 100644 --- a/src/main/worktree-removal-table.ts +++ b/src/main/worktree-removal-table.ts @@ -2,8 +2,6 @@ import { lstat } from 'node:fs/promises' import { getErrorCode } from './git/worktree-operation-options' import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktree-removal-records' -import type { RemoveWorktreeResult } from '../shared/worktree/create-types' -import type { GitWorktreeInfo } from '../shared/worktree/types' // The accepted removals, mirrored to disk on every change; listings and joins read only this. export const pendingWorktreeRemovals = new Map() @@ -43,26 +41,6 @@ export async function worktreeCheckoutExists(worktreePath: string): Promise[], - retry: () => Promise | undefined -): boolean { - if (registeredWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, worktreePath))) { - if (failedWorktreeRemovals.delete(worktreeId)) { - void persistWorktreeRemovalRecords() - } - return false - } - return retry() !== undefined -} - export function hasPendingWorktreeRemovals(): boolean { return pendingWorktreeRemovals.size > 0 } From c717fdd07b9e61cc7a6601b75f6630c48f6076b6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:54:41 -0700 Subject: [PATCH 23/31] test(worktrees): read Git's listing before the Delete call in the real-Git failed-delete suite --- .../runtime-failed-local-worktree-removal.test.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index dce35362af7d..cca0aa7f7807 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -97,14 +97,11 @@ function jobHost(purged: string[], stopPtys = vi.fn(async () => {})) { /** Delete on the row, as desktop and runtime run it: Git's listing first, then the retry. */ async function deleteRow(purged: string[], stopPtys = vi.fn(async () => {})): Promise { - const retried = await retryFailedRemovalUnlessRegistered( - worktreeId, - worktreePath, - await listWorktreesStrict(repoPath), - () => - retryFailedWorktreeRemoval(worktreeId, 'local', (record) => - interruptedLocalWorktreeRemovalJob(record, jobHost(purged, stopPtys)) - ) + const listed = await listWorktreesStrict(repoPath) + const retried = await retryFailedRemovalUnlessRegistered(worktreeId, worktreePath, listed, () => + retryFailedWorktreeRemoval(worktreeId, 'local', (record) => + interruptedLocalWorktreeRemovalJob(record, jobHost(purged, stopPtys)) + ) ).catch((reason: unknown) => reason) if (retried !== true) { return retried From f4d6115b57ce02f5d6eaac07cd8c9c1ecf6dce30 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:56:12 -0700 Subject: [PATCH 24/31] docs(worktrees): say when a failed delete's record ends under the Git-only delete --- src/main/worktree-removal-table.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/worktree-removal-table.ts b/src/main/worktree-removal-table.ts index 2c3be4e9055e..2bb7773c1bbd 100644 --- a/src/main/worktree-removal-table.ts +++ b/src/main/worktree-removal-table.ts @@ -5,9 +5,9 @@ import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from './workt // The accepted removals, mirrored to disk on every change; listings and joins read only this. export const pendingWorktreeRemovals = new Map() -// Deletes that failed after Git dropped the registration: listed with their error until Delete -// retries them, the checkout disappears or is replaced, or the repo leaves Orca. Never retried -// unasked. +// Failed deletes Git no longer registers, or a startup finish Git refused: listed with their error +// until the checkout disappears or is replaced, Delete finds Git registering it, or the repo leaves +// Orca. Never retried unasked. export const failedWorktreeRemovals = new Map() // Why weak: a listing that read Git before a delete finished holds the record until it replies. export const finishedWorktreeRemovals = new WeakSet() From 5dcbc2c34267b248061e8215b29021b7e514c6da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:00:16 -0700 Subject: [PATCH 25/31] fix(worktrees): keep main's handling of a startup finish Git refuses, and say the listing drops the workspace A startup finish that Git refuses for a checkout Git still registers now clears its record and leaves Git's normal row, exactly as an in-session failure does on main; the resumed-only failed record and the listing that put Git's error on Git's own row are gone, and the listing is main's again. An unregistered folder that still exists is still a failed row, and nothing is deleted. The refusal now reads: "Git no longer tracks , so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list." The listing drops a failed record once its folder is gone, as on main. --- src/main/ipc/worktrees-failed-removal.test.ts | 2 +- .../worktree-removal-failed-retry.spec.ts | 2 +- ...time-failed-local-worktree-removal.test.ts | 9 ++-- ...interrupted-local-worktree-removal.test.ts | 29 +++++------ ...time-interrupted-local-worktree-removal.ts | 12 ++++- src/main/worktree-background-removal.ts | 31 +++++++---- src/main/worktree-failed-removal.test.ts | 52 +------------------ src/main/worktree-removal-leftover.ts | 44 ++-------------- src/main/worktree-removal-listing.ts | 37 ++++--------- src/main/worktree-removal-table.ts | 6 +-- 10 files changed, 68 insertions(+), 156 deletions(-) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index 3e6bec95ae7b..b461f39bd697 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -181,7 +181,7 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { for (const force of [false, true]) { await expect(remove({ worktreeId: featureId, force })).rejects.toThrow( - "Git no longer tracks /workspace/feature-wt, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone." + "Git no longer tracks /workspace/feature-wt, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list." ) } await _settlePendingWorktreeRemovalsForTests() diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 12fea677e6e0..0feae3a2a198 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -88,7 +88,7 @@ describe('runtime Delete on a failed delete’s leftover', () => { waitForBackgroundRemoval: false }) ).rejects.toThrow( - `Git no longer tracks ${leftover}, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone.` + `Git no longer tracks ${leftover}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` ) } await _settlePendingWorktreeRemovalsForTests() diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index cca0aa7f7807..33558ad6b6e8 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -115,7 +115,7 @@ async function deleteRow(purged: string[], stopPtys = vi.fn(async () => {})): Pr } const refusal = (): string => - `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone.` + `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` /** A delete a quit interrupted, finished at the next start, where Git fails on the locked file. */ function failStartupFinish(): Promise { @@ -382,7 +382,7 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(purged).toEqual([]) }) - it('at startup, restores no missing .git and deletes nothing: the row shows Git’s error', async () => { + it('at startup, restores no missing .git and deletes nothing: Git’s row returns as before', async () => { await setImmutable(false) await rm(join(worktreePath, '.git')) const purged: string[] = [] @@ -395,8 +395,7 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await isRegistered(worktreePath)).toBe(true) expect(await git(['branch', '--list', 'feature'])).not.toBe('') expect(purged).toEqual([]) - expect(await listedRows()).toEqual([ - { path: worktreePath, removalError: expect.stringMatching(/validation failed/) } - ]) + expect(await listedRows()).toEqual([{ path: worktreePath }]) + expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) }) }) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index 86b543a60137..11d9aa8ee99c 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -194,7 +194,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(purged).toEqual([`repo-1::${worktreePath}`]) }) - it('keeps the user’s non-forced choice: Git refuses local changes and the row shows its error', async () => { + it('keeps the user’s non-forced choice: Git refuses local changes and the row returns as before', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) await writeFile(join(worktreePath, 'notes.txt'), 'mine\n') @@ -206,13 +206,11 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(await isRegistered(worktreePath)).toBe(true) expect(await git(['branch', '--list', 'feature'])).not.toBe('') expect(purged).toEqual([]) - expect(records).toMatchObject([{ failure: { message: expect.stringMatching(/--force/) } }]) - expect(await listedRows()).toEqual([ - { path: worktreePath, removalError: expect.stringMatching(/--force/) } - ]) + expect(records).toEqual([]) + expect(await listedRows()).toEqual([{ path: worktreePath }]) }) - it('restores no missing .git: a registered checkout without it becomes a failed row, files kept', async () => { + it('restores no missing .git: a registered checkout without it is left with its files', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) // Git deletes in directory order; without `.git` it refuses the checkout ("validation failed"). await unlink(join(worktreePath, '.git')) @@ -225,11 +223,10 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(removeHostTree).not.toHaveBeenCalled() expect(await isRegistered(worktreePath)).toBe(true) expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(outcome).toMatchObject({ error: expect.stringMatching(/validation failed/) }) expect(purged).toEqual([]) - expect(records).toHaveLength(1) - expect(await listedRows()).toEqual([ - { path: worktreePath, removalError: expect.stringMatching(/validation failed/) } - ]) + expect(records).toEqual([]) + expect(await listedRows()).toEqual([{ path: worktreePath }]) }) it('deletes nothing in a folder put at the path after Git unregistered the checkout', async () => { @@ -240,7 +237,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { const { outcome, purged, records } = await finishAfterRestart({ force: true }) - const refusal = `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone.` + const refusal = `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` expect(outcome).toEqual({ status: 'failed', error: `Error: ${refusal}` }) expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n') expect(removeHostTree).not.toHaveBeenCalled() @@ -310,7 +307,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(outcome).toMatchObject({ status: 'failed' }) expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) expect(purged).toEqual([]) - expect(records).toHaveLength(1) + expect(records).toEqual([]) }) it('deletes the branch when Git finished the checkout but the quit came before the branch', async () => { @@ -363,7 +360,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(purged).toEqual([`repo-1::${worktreePath}`]) }) - it('returns the row with the error when the finish fails, instead of retrying unseen', async () => { + it('returns the row live when the finish fails, instead of retrying unseen', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) // Another Git client locked it while Orca was not running. await git(['worktree', 'lock', worktreePath]) @@ -374,10 +371,8 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(existsSync(worktreePath)).toBe(true) expect(await isRegistered(worktreePath)).toBe(true) expect(purged).toEqual([]) - expect(records).toMatchObject([{ failure: { message: expect.stringMatching(/locked/) } }]) - expect(await listedRows()).toEqual([ - { path: worktreePath, removalError: expect.stringMatching(/locked/) } - ]) + expect(records).toEqual([]) + expect(await listedRows()).toEqual([{ path: worktreePath }]) }) it('drops a record whose repo Orca no longer has', async () => { diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index fe5740f364f0..47e07a497a7d 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -1,4 +1,5 @@ import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { Store } from '../persistence' import { @@ -9,13 +10,13 @@ import { resolveWorktreeRemovalMetadata } from '../worktree-removal-repo-owner' import type { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' import { listWorktreesStrict } from '../git/worktree' import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' +import { normalizeLocalBranchRef } from '../git/worktree-operation-options' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { assertUnregisteredCheckoutGone, - differentCheckoutAtPathError, - isRecordedCheckout + differentCheckoutAtPathError } from '../worktree-removal-leftover' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { WorktreeRemovalRecord } from '../worktree-removal-records' @@ -178,3 +179,10 @@ async function finishInterruptedLocalWorktreeRemoval( args.finishRemoval(result, true, record.head) return result } + +function isRecordedCheckout(worktree: GitWorktreeInfo, record: WorktreeRemovalRecord): boolean { + return ( + normalizeLocalBranchRef(worktree.branch) === record.branch && + (!record.head || worktree.head === record.head) + ) +} diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index fd1742063d11..e5c39c05321f 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -9,7 +9,7 @@ import { readWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktr import { differentCheckoutAtPathError, isCheckoutRegistered, - keepsFailedRemovalRow + isUnregisteredRemovalLeftover } from './worktree-removal-leftover' import { failedWorktreeRemovals, @@ -201,7 +201,7 @@ export function resumeInterruptedWorktreeRemovals( ): void { for (const record of pendingWorktreeRemovals.values()) { if (!jobsByWorktreeId.has(record.worktreeId)) { - runBackgroundWorktreeRemoval(record, jobFor(record), Promise.resolve(), true) + runBackgroundWorktreeRemoval(record, jobFor(record), Promise.resolve()) } } } @@ -216,8 +216,7 @@ export function stopBackgroundWorktreeRemovals(): void { function runBackgroundWorktreeRemoval( record: WorktreeRemovalRecord, job: BackgroundWorktreeRemovalJob, - recorded: Promise, - resumed = false + recorded: Promise ): void { const controller = new AbortController() stopControllers.add(controller) @@ -226,8 +225,7 @@ function runBackgroundWorktreeRemoval( settlementsByWorktreeId.get(record.worktreeId), job, recorded, - controller.signal, - resumed + controller.signal ).finally(() => { stopControllers.delete(controller) if (jobsByWorktreeId.get(record.worktreeId) === settled) { @@ -242,8 +240,7 @@ async function settleBackgroundWorktreeRemoval( settlement: RemovalSettlement | undefined, job: BackgroundWorktreeRemovalJob, recorded: Promise, - stopSignal: AbortSignal, - resumed: boolean + stopSignal: AbortSignal ): Promise { await waitForRecordWrite(record, recorded) let settle: (settlement: RemovalSettlement) => void @@ -264,7 +261,7 @@ async function settleBackgroundWorktreeRemoval( settle = (settlement) => settlement.reject(error) // Why: Git drops the registration even when it fails to delete the checkout, and Orca lists // workspaces from Git, so without the record the leftover would vanish with no way to retry. - if (await keepsFailedRemovalRow(record, resumed)) { + if (await isCheckoutLeftUnregistered(record)) { failure = { message: error instanceof Error ? error.message : String(error), failedAt: Date.now() @@ -299,6 +296,22 @@ async function settleBackgroundWorktreeRemoval( } } +async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promise { + if (!(await worktreeCheckoutExists(record.worktreePath))) { + return false + } + try { + return ( + !(await isCheckoutRegistered(record)) && + (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath)) + ) + } catch (error) { + // Unknowable: the row stays however Git lists it, as before this record existed. + console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) + return false + } +} + // Why: the record should be on disk before Git deletes, so a quit resumes the delete, but a disk or // file pool stall must not hold the user's delete; past this, a crash only loses the resume. const RECORD_WRITE_WAIT_MS = 2_000 diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 5d1795c38839..60f38feb47de 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -26,7 +26,7 @@ import { withUnregisteredRemovalCheckouts } from './worktree-removal-listing' import { retryFailedRemovalUnlessRegistered } from './worktree-removal-leftover' -import { readWorktreeRemovalRecords, writeWorktreeRemovalRecords } from './worktree-removal-records' +import { readWorktreeRemovalRecords } from './worktree-removal-records' import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-records-load' vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) @@ -300,7 +300,7 @@ describe('a delete that fails after Git dropped the registration', () => { await expect( retryFailedRemovalUnlessRegistered(worktreeId, checkout, [mainWorktree], retry) ).rejects.toThrow( - `Git no longer tracks ${checkout}, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone.` + `Git no longer tracks ${checkout}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` ) expect(retry).not.toHaveBeenCalled() @@ -320,52 +320,4 @@ describe('a delete that fails after Git dropped the registration', () => { expect(retry).toHaveBeenCalledTimes(1) }) - - it('a delete resumed at startup that fails on a checkout Git still registers shows the error on Git’s row', async () => { - const registered = { ...leftoverRow(), removalError: undefined } - vi.mocked(listWorktreesStrict).mockResolvedValue([mainWorktree, registered]) - const record = { - worktreeId, - repoId: 'repo-1', - repoPath: '/work/repo', - worktreePath: checkout, - branch: 'feature', - head: 'abc', - deleteBranch: true, - force: false, - requestedAt: 1 - } - _resetPendingWorktreeRemovalsForTests() - await writeWorktreeRemovalRecords(join(directory, 'profile'), () => [record]) - await loadWorktreeRemovalRecords(join(directory, 'profile')) - - resumeInterruptedWorktreeRemovals(() => ({ - run: async () => { - throw new Error('contains modified or untracked files, use --force to delete it') - }, - publish: () => {} - })) - await _settlePendingWorktreeRemovalsForTests() - - expect(await withUnregisteredRemovalCheckouts('repo-1', [mainWorktree, registered])).toEqual([ - mainWorktree, - { ...registered, removalError: expect.stringMatching(/--force/) } - ]) - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toMatchObject([ - { worktreeId, failure: { message: expect.stringMatching(/--force/) } } - ]) - }) - - it('ends at the next listing once Git lists a different checkout at the path', async () => { - await failRemoval() - const other = { ...leftoverRow(), branch: 'refs/heads/other', removalError: undefined } - - expect(await withUnregisteredRemovalCheckouts('repo-1', [mainWorktree, other])).toEqual([ - mainWorktree, - other - ]) - await vi.waitFor(async () => - expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) - ) - }) }) diff --git a/src/main/worktree-removal-leftover.ts b/src/main/worktree-removal-leftover.ts index dfbb0127cc9f..63bee3d67268 100644 --- a/src/main/worktree-removal-leftover.ts +++ b/src/main/worktree-removal-leftover.ts @@ -3,10 +3,9 @@ import { join } from 'node:path' import type { RemoveWorktreeResult } from '../shared/worktree/create-types' import type { GitWorktreeInfo } from '../shared/worktree/types' import { listWorktreesStrict } from './git/worktree' -import { getErrorCode, normalizeLocalBranchRef } from './git/worktree-operation-options' +import { getErrorCode } from './git/worktree-operation-options' import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { CLIENT_REMOVAL_HOME } from './worktree-removal-home-guard' -import type { WorktreeRemovalRecord } from './worktree-removal-records' import { failedWorktreeRemovals, persistWorktreeRemovalRecords, @@ -38,17 +37,6 @@ export function differentCheckoutAtPathError(worktreePath: string): Error { ) } -/** Whether a checkout Git registers is the one the removal was accepted for. */ -export function isRecordedCheckout( - worktree: Pick, - record: { branch: string; head: string } -): boolean { - return ( - normalizeLocalBranchRef(worktree.branch) === record.branch && - (!record.head || worktree.head === record.head) - ) -} - /** Whether Git registers a checkout at the recorded path now. */ export async function isCheckoutRegistered(record: { repoPath: string @@ -59,39 +47,13 @@ export async function isCheckoutRegistered(record: { ) } -/** - * Whether a failed delete stays listed with its error: its leftover Git no longer registers, or, - * for a delete resumed at startup, the recorded checkout Git still registers, since nobody waits on - * that delete's reply to see the error. - */ -export async function keepsFailedRemovalRow( - record: WorktreeRemovalRecord, - resumed: boolean -): Promise { - if (!(await worktreeCheckoutExists(record.worktreePath))) { - return false - } - try { - const registered = (await listWorktreesStrict(record.repoPath)).find((worktree) => - areWorktreePathsEqual(worktree.path, record.worktreePath) - ) - return registered - ? resumed && isRecordedCheckout(registered, record) - : await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath) - } catch (error) { - // Unknowable: the row stays however Git lists it, as before this record existed. - console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) - return false - } -} - /** * The refusal for a folder Git no longer registers: Orca deletes a checkout only through Git, so - * the user removes the folder, and the workspace ends once it is gone. + * the user removes the folder, and the listing drops the workspace once it is gone. */ export function unregisteredFolderRefusal(worktreePath: string): Error { return new Error( - `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself; Orca removes this workspace once the folder is gone.` + `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` ) } diff --git a/src/main/worktree-removal-listing.ts b/src/main/worktree-removal-listing.ts index 7e2bb61b2c56..fd4dd7bfce97 100644 --- a/src/main/worktree-removal-listing.ts +++ b/src/main/worktree-removal-listing.ts @@ -1,7 +1,7 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' import type { GitWorktreeInfo } from '../shared/worktree/types' import { areWorktreePathsEqual } from './git/worktree-path-comparison' -import { isRecordedCheckout, isUnregisteredRemovalLeftover } from './worktree-removal-leftover' +import { isUnregisteredRemovalLeftover } from './worktree-removal-leftover' import type { WorktreeRemovalRecord } from './worktree-removal-records' import { failedWorktreeRemovals, @@ -19,43 +19,26 @@ const NO_PENDING_REMOVALS: PendingWorktreeRemovals = new Map() /** * Git's rows for a local repo plus one for each removal this host still owns whose checkout Git no * longer lists but is still on disk: a failed delete (carrying its error) or one still finishing. - * A failed delete of a checkout Git still lists carries its error on Git's row. A failed delete ends - * here once its checkout is gone or a different checkout took the path. + * A failed delete ends here once its checkout is gone or a different checkout took the path. */ export async function withUnregisteredRemovalCheckouts( repoId: string, gitWorktrees: GitWorktreeInfo[] ): Promise { - const records = [...pendingWorktreeRemovals.values(), ...failedWorktreeRemovals.values()].filter( - (record) => record.repoId === repoId + const unlisted = [...pendingWorktreeRemovals.values(), ...failedWorktreeRemovals.values()].filter( + (record) => + record.repoId === repoId && + !gitWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath)) ) - if (records.length === 0) { + if (unlisted.length === 0) { return gitWorktrees } - let rows = gitWorktrees const leftovers: GitWorktreeInfo[] = [] let droppedFailure = false - for (const record of records) { + for (const record of unlisted) { const failed = failedWorktreeRemovals.get(record.worktreeId) === record - const listed = gitWorktrees.find((worktree) => - areWorktreePathsEqual(worktree.path, record.worktreePath) - ) - if (listed && !failed) { - continue - } - const exists = await worktreeCheckoutExists(record.worktreePath) - if (listed) { - if (exists && record.failure && isRecordedCheckout(listed, record)) { - const removalError = record.failure.message - rows = rows.map((row) => (row === listed ? { ...row, removalError } : row)) - } else { - failedWorktreeRemovals.delete(record.worktreeId) - droppedFailure = true - } - continue - } if ( - exists && + (await worktreeCheckoutExists(record.worktreePath)) && (!failed || (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath))) ) { leftovers.push({ @@ -74,7 +57,7 @@ export async function withUnregisteredRemovalCheckouts( if (droppedFailure) { void persistWorktreeRemovalRecords() } - return leftovers.length === 0 ? rows : [...rows, ...leftovers] + return leftovers.length === 0 ? gitWorktrees : [...gitWorktrees, ...leftovers] } /** Taken before a listing reads Git; pass it to projectPendingWorktreeRemovals with the rows. */ diff --git a/src/main/worktree-removal-table.ts b/src/main/worktree-removal-table.ts index 2bb7773c1bbd..857aba660143 100644 --- a/src/main/worktree-removal-table.ts +++ b/src/main/worktree-removal-table.ts @@ -5,9 +5,9 @@ import { writeWorktreeRemovalRecords, type WorktreeRemovalRecord } from './workt // The accepted removals, mirrored to disk on every change; listings and joins read only this. export const pendingWorktreeRemovals = new Map() -// Failed deletes Git no longer registers, or a startup finish Git refused: listed with their error -// until the checkout disappears or is replaced, Delete finds Git registering it, or the repo leaves -// Orca. Never retried unasked. +// Deletes that failed after Git dropped the registration: listed with their error until the +// checkout disappears or is replaced, Delete finds Git registering it, or the repo leaves Orca. +// Never retried unasked. export const failedWorktreeRemovals = new Map() // Why weak: a listing that read Git before a delete finished holds the record until it replies. export const finishedWorktreeRemovals = new WeakSet() From 75d54efc3c140d8a1b3d8b8ae92079bbd4e3262b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:27:04 -0700 Subject: [PATCH 26/31] fix(worktrees): finish a failed delete's branch and push-target remote when it fails A delete Git fails partway keeps a "Delete failed" row for the folder Git left. The row's record also carried the branch delete and the push-target remote cleanup for later, but the listing drops the record once the user removes the folder, so both were silently left behind. Now, once Git no longer registers the checkout, the failure runs that Git side right away (merged branch only, stale admin entry, push-target remote), in the one settle path desktop and runtime share. It deletes no files, and a failure of this bookkeeping is logged without touching the failed row. --- src/main/git/worktree-removal.ts | 15 ++++- .../remove-registered-local-worktree.ts | 34 +++++----- ...time-failed-local-worktree-removal.test.ts | 42 +++++++++--- ...interrupted-local-worktree-removal.test.ts | 3 +- ...time-interrupted-local-worktree-removal.ts | 29 ++++++-- ...ntime-registered-local-worktree-removal.ts | 3 +- src/main/worktree-background-removal.ts | 32 ++------- src/main/worktree-failed-removal.test.ts | 53 +++++++++++++-- src/main/worktree-removal-failure.ts | 66 +++++++++++++++++++ src/main/worktree-removal-records.ts | 5 +- 10 files changed, 215 insertions(+), 67 deletions(-) create mode 100644 src/main/worktree-removal-failure.ts diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 7b5cdeb05bfe..d2228e2a4170 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -122,9 +122,22 @@ export async function finishUnregisteredWorktreeRemoval( branch: { name: string; head: string } | null, assertCheckoutGone: () => Promise, options: RemoveWorktreeOptions = {} +): Promise { + await assertCheckoutGone() + return finishGitSideOfUnregisteredWorktree(repoPath, worktreePath, branch, options) +} + +/** + * The Git side of a removal once Git no longer registers the checkout, whatever is left on disk: + * stale admin records, then the branch (merged only, as `removeWorktree` does). Deletes no files. + */ +export async function finishGitSideOfUnregisteredWorktree( + repoPath: string, + worktreePath: string, + branch: { name: string; head: string } | null, + options: RemoveWorktreeOptions = {} ): Promise { try { - await assertCheckoutGone() await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options)).catch( (error: unknown) => console.warn(`[git] worktree prune failed in ${repoPath}`, error) ) diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index a75a2607c9e5..4e7eb87074da 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -128,6 +128,14 @@ export async function removeRegisteredLocalWorktree( } } + const cleanupPushTarget = () => + cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + context.store, + localWorktreeGitOptions + ) const finish = (checkoutDeleteSignal?: AbortSignal): Promise => finishLocalWorktreeRemoval({ context, @@ -142,7 +150,8 @@ export async function removeRegisteredLocalWorktree( deleteBranch, refreshedRegisteredWorktree, removalGate, - checkoutDeleteSignal + checkoutDeleteSignal, + cleanupPushTarget }) if (!removesInBackground(canonicalWorktreePath, localWorktreeGitOptions)) { const result = await finish() @@ -169,7 +178,8 @@ export async function removeRegisteredLocalWorktree( }) return result }, - publish: () => runtime.publishWorktreeRemovalChange(repoId) + publish: () => runtime.publishWorktreeRemovalChange(repoId), + cleanupPushTargetRemote: cleanupPushTarget }) return { removing: true } } @@ -187,7 +197,8 @@ async function finishLocalWorktreeRemoval({ deleteBranch, refreshedRegisteredWorktree, removalGate, - checkoutDeleteSignal + checkoutDeleteSignal, + cleanupPushTarget }: { context: WorktreeIpcContext args: RemoveWorktreeArgs @@ -202,6 +213,7 @@ async function finishLocalWorktreeRemoval({ refreshedRegisteredWorktree: GitWorktreeInfo removalGate: { finish: (removed: boolean) => Promise } checkoutDeleteSignal?: AbortSignal + cleanupPushTarget: () => Promise }): Promise { const { store, runtime } = context let removalResult: RemoveWorktreeResult | undefined @@ -252,13 +264,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, @@ -287,13 +293,7 @@ async function finishLocalWorktreeRemoval({ } finally { await removalGate.finish(removalCompleted) } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) + await cleanupPushTarget() rememberPreservedBranchCleanupTarget( args.worktreeId, removalHostId, diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 33558ad6b6e8..988ebf00ad6b 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -208,10 +208,11 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part const error = await failStartupFinish() expect(String(error)).toMatch(/Operation not permitted/) - // What Git left: no registration, but the checkout, the branch and Orca's record. + // What Git left: no registration, but the checkout and Orca's record. The merged branch went + // with the failed delete, as its finish would have deleted it. expect(await isRegistered(worktreePath)).toBe(false) expect(existsSync(lockedFile)).toBe(true) - expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(await git(['branch', '--list', 'feature'])).toBe('') expect(await listedRows()).toEqual([ { path: worktreePath, removalError: expect.stringMatching(/Operation not permitted/) } ]) @@ -243,7 +244,7 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await listedRows()).toHaveLength(1) }) - it('Delete refuses while the leftover is there, and finishes branch, metadata and record once the user removed it', async () => { + it('Delete refuses while the leftover is there, and finishes metadata and record once the user removed it', async () => { await failInSession() await setImmutable(false) const purged: string[] = [] @@ -280,7 +281,6 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n') expect(removeHostTree).not.toHaveBeenCalled() - expect(await git(['branch', '--list', 'feature'])).not.toBe('') expect(purged).toEqual([]) expect(await listedRows()).toEqual([ { path: worktreePath, removalError: expect.stringMatching(/Operation not permitted/) } @@ -290,13 +290,38 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part ]) }) - it('the record ends once the checkout is deleted outside Orca', async () => { + it('deletes the merged branch when the delete fails, and only the folder is left to the user', async () => { await failInSession() + + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(existsSync(lockedFile)).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(await listedRows()).toEqual([ + { path: worktreePath, removalError: expect.stringMatching(/Operation not permitted/) } + ]) + + // The listing ends the record once the folder is gone; nothing of the delete is left owed. await setImmutable(false) await rm(worktreePath, { recursive: true }) - expect(await listedRows()).toEqual([]) await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([])) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(await git(['worktree', 'list', '--porcelain'])).not.toContain(worktreePath) + }) + + it('keeps an unmerged branch when the delete fails, as a normal delete does', async () => { + await writeFile(join(worktreePath, 'work.txt'), 'work\n') + await git(['add', 'work.txt'], worktreePath) + await git(['commit', '-qm', 'work'], worktreePath) + const head = (await git(['rev-parse', 'feature'])).trim() + + expect(String(await failInSession())).toMatch(/Operation not permitted/) + + expect((await git(['rev-parse', 'feature'])).trim()).toBe(head) + expect(existsSync(lockedFile)).toBe(true) + expect(await listedRows()).toEqual([ + { path: worktreePath, removalError: expect.stringMatching(/Operation not permitted/) } + ]) }) it('never deletes a different checkout created at the path since', async () => { @@ -326,7 +351,8 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part await failStartupFinish() await setImmutable(false) await rm(worktreePath, { recursive: true }) - await git(['worktree', 'add', '-q', worktreePath, 'feature']) + // The failed delete already deleted the merged branch; the user makes it again. + await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature']) await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') const purged: string[] = [] @@ -378,7 +404,7 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(String(await deleteRow(purged, stopPtys))).toBe(`Error: ${refusal()}`) expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n') - expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(removeHostTree).not.toHaveBeenCalled() expect(purged).toEqual([]) }) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index 11d9aa8ee99c..cc537470390b 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -241,7 +241,8 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(outcome).toEqual({ status: 'failed', error: `Error: ${refusal}` }) expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n') expect(removeHostTree).not.toHaveBeenCalled() - expect(await git(['branch', '--list', 'feature'])).not.toBe('') + // Git let go of the checkout, so the recorded branch delete runs (merged only); no files. + expect(await git(['branch', '--list', 'feature'])).toBe('') expect(purged).toEqual([]) expect(records).toMatchObject([{ failure: { message: refusal } }]) expect(await listedRows()).toEqual([{ path: worktreePath, removalError: refusal }]) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 47e07a497a7d..a9eff0afa257 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -9,6 +9,7 @@ import { import { resolveWorktreeRemovalMetadata } from '../worktree-removal-repo-owner' import type { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' import { listWorktreesStrict } from '../git/worktree' +import { cleanupUnusedWorktreePushTargetRemote } from '../ipc/worktree-remote' import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' import { normalizeLocalBranchRef } from '../git/worktree-operation-options' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' @@ -44,14 +45,16 @@ export function interruptedLocalWorktreeRemovalJob( record: WorktreeRemovalRecord, host: InterruptedWorktreeRemovalHost ): BackgroundWorktreeRemovalJob { + const resolveRemovedPushTarget = () => + resolveWorktreeRemovalMetadata( + host.store, + record.repoId, + record.worktreeId, + LOCAL_EXECUTION_HOST_ID + )?.pushTarget return { run: async (stopSignal) => { - const removedPushTarget = resolveWorktreeRemovalMetadata( - host.store, - record.repoId, - record.worktreeId, - LOCAL_EXECUTION_HOST_ID - )?.pushTarget + const removedPushTarget = resolveRemovedPushTarget() const result = await finishInterruptedLocalWorktreeRemoval({ record, store: host.store, @@ -81,7 +84,19 @@ export function interruptedLocalWorktreeRemovalJob( host.onRemoved(record) return result }, - publish: () => host.publish(record.repoId) + publish: () => host.publish(record.repoId), + cleanupPushTargetRemote: async () => { + const repo = host.store.getRepo(record.repoId) + if (repo) { + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + record.worktreeId, + resolveRemovedPushTarget(), + host.store, + getLocalProjectWorktreeGitOptions(host.store, repo) + ) + } + } } } diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index a5e966b1471a..7fc172543fd9 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -182,7 +182,8 @@ async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: { args.onRemoved() return result }, - publish: () => args.publish() + publish: () => args.publish(), + cleanupPushTargetRemote: () => cleanupRemovedWorktreePushTarget(args) }) return { removing: true, ...acceptedFields } } diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts index e5c39c05321f..487f4a671983 100644 --- a/src/main/worktree-background-removal.ts +++ b/src/main/worktree-background-removal.ts @@ -6,11 +6,8 @@ import { acquireWatcherRemovalGate, type WatcherRemovalGate } from './ipc/watche import { runWorktreeChangeInvalidators } from './ipc/worktree-change-invalidators' import { parseWslPath } from './wsl' import { readWorktreeRemovalRecords, type WorktreeRemovalRecord } from './worktree-removal-records' -import { - differentCheckoutAtPathError, - isCheckoutRegistered, - isUnregisteredRemovalLeftover -} from './worktree-removal-leftover' +import { failureLeftByGit } from './worktree-removal-failure' +import { differentCheckoutAtPathError, isCheckoutRegistered } from './worktree-removal-leftover' import { failedWorktreeRemovals, finishedWorktreeRemovals, @@ -25,6 +22,8 @@ export type BackgroundWorktreeRemovalJob = { run: (stopSignal: AbortSignal) => Promise /** Fires when the row starts showing as removing, and again after it has left the table. */ publish: () => void + /** The push-target remote cleanup a finished delete runs; also run when Git fails partway. */ + cleanupPushTargetRemote?: () => Promise } type RemovalSettlement = { @@ -261,12 +260,7 @@ async function settleBackgroundWorktreeRemoval( settle = (settlement) => settlement.reject(error) // Why: Git drops the registration even when it fails to delete the checkout, and Orca lists // workspaces from Git, so without the record the leftover would vanish with no way to retry. - if (await isCheckoutLeftUnregistered(record)) { - failure = { - message: error instanceof Error ? error.message : String(error), - failedAt: Date.now() - } - } + failure = await failureLeftByGit(record, error, job.cleanupPushTargetRemote) } finally { // A resumed job that ended before taking its own gate still holds the fence loading gave it. releaseStartupRemovalFence(record.worktreeId) @@ -296,22 +290,6 @@ async function settleBackgroundWorktreeRemoval( } } -async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promise { - if (!(await worktreeCheckoutExists(record.worktreePath))) { - return false - } - try { - return ( - !(await isCheckoutRegistered(record)) && - (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath)) - ) - } catch (error) { - // Unknowable: the row stays however Git lists it, as before this record existed. - console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) - return false - } -} - // Why: the record should be on disk before Git deletes, so a quit resumes the delete, but a disk or // file pool stall must not hold the user's delete; past this, a crash only loses the resume. const RECORD_WRITE_WAIT_MS = 2_000 diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 60f38feb47de..0f8960fd362c 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -9,6 +9,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitWorktreeInfo } from '../shared/worktree/types' import { listWorktreesStrict } from './git/worktree' +import type * as WorktreeRemovalModule from './git/worktree-removal' +import { finishGitSideOfUnregisteredWorktree } from './git/worktree-removal' import { beginTerminalInstall } from './ipc/watcher-removal-gate' import { registerWorktreeChangeInvalidator } from './ipc/worktree-change-invalidators' import { @@ -18,7 +20,8 @@ import { resumeInterruptedWorktreeRemovals, retryFailedWorktreeRemoval, startBackgroundWorktreeRemoval, - waitForPendingWorktreeRemoval + waitForPendingWorktreeRemoval, + type BackgroundWorktreeRemovalJob } from './worktree-background-removal' import { projectPendingWorktreeRemovals, @@ -30,6 +33,10 @@ import { readWorktreeRemovalRecords } from './worktree-removal-records' import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-records-load' vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) +vi.mock('./git/worktree-removal', async (importOriginal) => ({ + ...(await importOriginal()), + finishGitSideOfUnregisteredWorktree: vi.fn(async () => ({})) +})) const GIT_ERROR = "error: failed to delete 'node_modules/a/LICENSE': Operation not permitted" let directory = '' @@ -58,11 +65,14 @@ beforeEach(async () => { afterEach(async () => { _resetPendingWorktreeRemovalsForTests() + vi.mocked(finishGitSideOfUnregisteredWorktree).mockReset() vi.restoreAllMocks() await rm(directory, { recursive: true, force: true }) }) -function startFailingRemoval(): Promise { +function startFailingRemoval( + job: Pick = {} +): Promise { return startBackgroundWorktreeRemoval({ removal: { worktreeId, @@ -75,7 +85,8 @@ function startFailingRemoval(): Promise { run: async () => { throw new Error(GIT_ERROR) }, - publish: () => {} + publish: () => {}, + ...job }) } @@ -133,9 +144,43 @@ describe('a delete that fails after Git dropped the registration', () => { mainWorktree, { ...leftoverRow(), removalError: undefined } ]) - await failRemoval() + const cleanupPushTargetRemote = vi.fn(async () => {}) + await expect(startFailingRemoval({ cleanupPushTargetRemote })).rejects.toThrow(GIT_ERROR) + await _settlePendingWorktreeRemovalsForTests() expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) + // Git still has the checkout, so its branch and remote stay with it. + expect(finishGitSideOfUnregisteredWorktree).not.toHaveBeenCalled() + expect(cleanupPushTargetRemote).not.toHaveBeenCalled() + }) + + it('runs the Git side of the delete when it fails: the recorded branch and the push-target remote', async () => { + const cleanupPushTargetRemote = vi.fn(async () => {}) + await expect(startFailingRemoval({ cleanupPushTargetRemote })).rejects.toThrow(GIT_ERROR) + await _settlePendingWorktreeRemovalsForTests() + + expect(finishGitSideOfUnregisteredWorktree).toHaveBeenCalledWith('/work/repo', checkout, { + name: 'feature', + head: 'abc' + }) + expect(cleanupPushTargetRemote).toHaveBeenCalledTimes(1) + expect(await readdir(checkout)).toEqual(['node_modules']) + expect(await listRows()).toEqual([mainWorktree, leftoverRow()]) + }) + + it('keeps the failed row and its error when that bookkeeping fails', async () => { + vi.mocked(finishGitSideOfUnregisteredWorktree).mockRejectedValue(new Error('branch locked')) + const cleanupPushTargetRemote = vi.fn(async () => { + throw new Error('remote busy') + }) + await expect(startFailingRemoval({ cleanupPushTargetRemote })).rejects.toThrow(GIT_ERROR) + await _settlePendingWorktreeRemovalsForTests() + + expect(cleanupPushTargetRemote).toHaveBeenCalledTimes(1) + expect(await listRows()).toEqual([mainWorktree, leftoverRow()]) + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toMatchObject([ + { worktreeId, failure: { message: GIT_ERROR } } + ]) }) it('clears the record as before when the checkout is gone', async () => { diff --git a/src/main/worktree-removal-failure.ts b/src/main/worktree-removal-failure.ts new file mode 100644 index 000000000000..99c2ee1842b2 --- /dev/null +++ b/src/main/worktree-removal-failure.ts @@ -0,0 +1,66 @@ +import { finishGitSideOfUnregisteredWorktree } from './git/worktree-removal' +import { isCheckoutRegistered, isUnregisteredRemovalLeftover } from './worktree-removal-leftover' +import type { WorktreeRemovalFailure, WorktreeRemovalRecord } from './worktree-removal-records' +import { worktreeCheckoutExists } from './worktree-removal-table' + +/** + * The failure to keep for a delete that failed after Git dropped the registration with the + * checkout still on disk, once the rest of the delete's Git side ran; undefined otherwise. + */ +export async function failureLeftByGit( + record: WorktreeRemovalRecord, + error: unknown, + cleanupPushTargetRemote: (() => Promise) | undefined +): Promise { + if (!(await isCheckoutLeftUnregistered(record))) { + return undefined + } + const failure = { + message: error instanceof Error ? error.message : String(error), + failedAt: Date.now() + } + await finishGitSideOfFailedRemoval(record, cleanupPushTargetRemote) + return failure +} + +/** + * What the delete's finish would have done once Git let go of the checkout, run in the same + * operation: the branch per the recorded choice and the push-target remote. Deletes no files, so + * the failed record owes only the folder, which the user removes. + */ +async function finishGitSideOfFailedRemoval( + record: WorktreeRemovalRecord, + cleanupPushTargetRemote: (() => Promise) | undefined +): Promise { + // Why caught: bookkeeping; the failed row and the request's error stand either way. + try { + await finishGitSideOfUnregisteredWorktree( + record.repoPath, + record.worktreePath, + record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null + ) + } catch (error) { + console.warn(`[worktrees] could not delete the branch of ${record.worktreePath}`, error) + } + try { + await cleanupPushTargetRemote?.() + } catch (error) { + console.warn(`[worktrees] could not clean up the push target of ${record.worktreePath}`, error) + } +} + +async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promise { + if (!(await worktreeCheckoutExists(record.worktreePath))) { + return false + } + try { + return ( + !(await isCheckoutRegistered(record)) && + (await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath)) + ) + } catch (error) { + // Unknowable: the row stays however Git lists it, as before this record existed. + console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error) + return false + } +} diff --git a/src/main/worktree-removal-records.ts b/src/main/worktree-removal-records.ts index b4e7dbdb7fa7..f5df4579a11a 100644 --- a/src/main/worktree-removal-records.ts +++ b/src/main/worktree-removal-records.ts @@ -24,7 +24,10 @@ export type WorktreeRemovalRecord = { deleteBranch: boolean force: boolean requestedAt: number - /** The delete failed after Git dropped the registration with the checkout still on disk. */ + /** + * The delete failed after Git dropped the registration with the checkout still on disk. Its Git + * side (branch, push-target remote) ran when it failed; the record owes only the folder. + */ failure?: WorktreeRemovalFailure } From 40007899e792a378a59809bbb6c4f8e0e8dfa88a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:30:53 -0700 Subject: [PATCH 27/31] fix(worktrees): unregister an interrupted delete's checkout whose .git Git already deleted A quit mid-delete can leave a checkout Git still registers but whose .git link Git already deleted (Git deletes it first on Windows). No `git worktree remove` can validate it, so the startup finish failed, the record was cleared, and the workspace came back as a normal row no Delete could ever remove. The startup finish now has Git prune that registration (refused while locked; every file kept) and then treats the folder as one Git no longer registers: the "Delete failed" row with the remove-it-yourself message, ending once the user removes the folder. Adds the non-forced partial-delete case: Git refuses, the files stay, and the row returns as before. --- ...time-failed-local-worktree-removal.test.ts | 18 +++++-- ...interrupted-local-worktree-removal.test.ts | 46 +++++++++++++---- ...time-interrupted-local-worktree-removal.ts | 49 +++++++++++++++++-- 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 988ebf00ad6b..e5e84a7cb6ee 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -408,20 +408,28 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(purged).toEqual([]) }) - it('at startup, restores no missing .git and deletes nothing: Git’s row returns as before', async () => { + it('at startup, unregisters a checkout whose .git is gone, keeping its files, until the user removes it', async () => { await setImmutable(false) await rm(join(worktreePath, '.git')) const purged: string[] = [] - expect(String(await finishAtStartup(purged))).toMatch(/validation failed/) + expect(String(await finishAtStartup(purged))).toBe(`Error: ${refusal()}`) expect(existsSync(join(worktreePath, '.git'))).toBe(false) expect(existsSync(lockedFile)).toBe(true) expect(removeHostTree).not.toHaveBeenCalled() - expect(await isRegistered(worktreePath)).toBe(true) - expect(await git(['branch', '--list', 'feature'])).not.toBe('') + expect(await isRegistered(worktreePath)).toBe(false) + expect(await git(['branch', '--list', 'feature'])).toBe('') expect(purged).toEqual([]) - expect(await listedRows()).toEqual([{ path: worktreePath }]) + expect(await listedRows()).toEqual([{ path: worktreePath, removalError: refusal() }]) + + expect(String(await deleteRow(purged))).toBe(`Error: ${refusal()}`) + expect(existsSync(lockedFile)).toBe(true) + + await rm(worktreePath, { recursive: true }) + expect(await deleteRow(purged)).toBeUndefined() + expect(purged).toEqual([worktreeId]) expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + expect(await listedRows()).toEqual([]) }) }) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index cc537470390b..c2997e02085b 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -210,20 +210,44 @@ describe('finishing an interrupted worktree removal after a restart', () => { expect(await listedRows()).toEqual([{ path: worktreePath }]) }) - it('restores no missing .git: a registered checkout without it is left with its files', async () => { + it.each([true, false])( + 'unregisters a checkout whose .git Git deleted first, keeping its files (force %s)', + async (force) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + // Git deletes in directory order; without `.git` no `worktree remove` can validate it. + await unlink(join(worktreePath, '.git')) + + const { outcome, purged, records } = await finishAfterRestart({ force }) + + const refusal = `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` + expect(outcome).toEqual({ status: 'failed', error: `Error: ${refusal}` }) + expect(existsSync(join(worktreePath, '.git'))).toBe(false) + expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(await isRegistered(worktreePath)).toBe(false) + // The merged branch goes with the registration, as the delete asked. + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([]) + expect(records).toMatchObject([{ failure: { message: refusal } }]) + expect(await listedRows()).toEqual([{ path: worktreePath, removalError: refusal }]) + } + ) + + it('keeps the user’s non-forced choice after Git deleted part of the checkout: the row returns as before', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) - // Git deletes in directory order; without `.git` it refuses the checkout ("validation failed"). - await unlink(join(worktreePath, '.git')) + await writeFile(join(worktreePath, 'kept.txt'), 'kept\n') + await git(['add', 'kept.txt'], worktreePath) + await git(['commit', '-qm', 'kept'], worktreePath) + // Git stopped partway: some tracked files are gone, `.git` is still there. + await unlink(join(worktreePath, 'seed.txt')) - const { outcome, purged, records } = await finishAfterRestart({ force: true }) + const { outcome, purged, records } = await finishAfterRestart({ force: false }) - expect(outcome).toMatchObject({ status: 'failed' }) - expect(existsSync(join(worktreePath, '.git'))).toBe(false) - expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) - expect(removeHostTree).not.toHaveBeenCalled() + expect(outcome).toMatchObject({ status: 'failed', error: expect.stringMatching(/--force/) }) + expect(await readFile(join(worktreePath, 'kept.txt'), 'utf8')).toBe('kept\n') + expect(existsSync(join(worktreePath, '.git'))).toBe(true) expect(await isRegistered(worktreePath)).toBe(true) expect(await git(['branch', '--list', 'feature'])).not.toBe('') - expect(outcome).toMatchObject({ error: expect.stringMatching(/validation failed/) }) expect(purged).toEqual([]) expect(records).toEqual([]) expect(await listedRows()).toEqual([{ path: worktreePath }]) @@ -305,8 +329,10 @@ describe('finishing an interrupted worktree removal after a restart', () => { const { outcome, purged, records } = await finishAfterRestart({ force: true }) - expect(outcome).toMatchObject({ status: 'failed' }) + expect(outcome).toMatchObject({ status: 'failed', error: expect.stringMatching(/locked/) }) expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) + // Not pruned either: the lock keeps Git's registration. + expect(await isRegistered(worktreePath)).toBe(true) expect(purged).toEqual([]) expect(records).toEqual([]) }) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index a9eff0afa257..9b9f0357bdd7 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -11,7 +11,7 @@ import type { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-c import { listWorktreesStrict } from '../git/worktree' import { cleanupUnusedWorktreePushTargetRemote } from '../ipc/worktree-remote' import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' -import { normalizeLocalBranchRef } from '../git/worktree-operation-options' +import { getErrorCode, normalizeLocalBranchRef } from '../git/worktree-operation-options' import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' @@ -19,7 +19,13 @@ import { assertUnregisteredCheckoutGone, differentCheckoutAtPathError } from '../worktree-removal-leftover' -import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' +import { CLIENT_REMOVAL_HOME, getPathOps } from '../worktree-removal-home-guard' +import { removeStaleLocalWorktreeRegistration } from '../local-worktree-removal-recovery' +import { + getLocalWorktreePathAccess, + toLocalWorktreeRuntimePath, + type LocalWorktreeFilesystemOptions +} from '../local-worktree-filesystem' import type { WorktreeRemovalRecord } from '../worktree-removal-records' import { cleanupRemovedWorktreePushTarget, @@ -114,8 +120,8 @@ type InterruptedLocalWorktreeRemovalArgs = Pick< /** * Finishes a removal a quit or crash interrupted, or a failed one whose folder the user removed. * What is left comes from Git and disk, not the record: a checkout Git registers is deleted by Git - * with the recorded choices; a folder Git no longer registers is refused, never deleted; with the - * folder gone, the rest of the delete finishes. + * with the recorded choices, or only unregistered when its `.git` is gone; a folder Git no longer + * registers is refused, never deleted; with the folder gone, the rest of the delete finishes. */ async function finishInterruptedLocalWorktreeRemoval( args: InterruptedLocalWorktreeRemovalArgs @@ -143,7 +149,7 @@ async function finishInterruptedLocalWorktreeRemoval( const registered = worktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath) ) - const deletable = registered + let deletable = registered ? findRegisteredDeletableWorktree( repo.path, record.worktreePath, @@ -159,6 +165,18 @@ async function finishInterruptedLocalWorktreeRemoval( if (deletable && !isRecordedCheckout(deletable, record)) { throw differentCheckoutAtPathError(record.worktreePath) } + if (deletable && (await isCheckoutMissingGitLink(deletable.path, localOptions))) { + // Why: Git can neither remove nor validate a checkout whose `.git` it deleted first (Windows + // order); prune drops only the registration, keeping every file, so the checks below apply. + await removeStaleLocalWorktreeRegistration({ + canonicalWorktreePath: deletable.path, + repoPath: repo.path, + localWorktreeGitOptions: localOptions, + registeredWorktree: deletable, + deleteBranch: false + }) + deletable = undefined + } // Before the teardown, so a refused folder keeps its terminals and watchers. if (!deletable) { await assertUnregisteredCheckoutGone(record.worktreePath) @@ -195,6 +213,27 @@ async function finishInterruptedLocalWorktreeRemoval( return result } +/** A checkout folder still on disk whose `.git` link is gone. */ +async function isCheckoutMissingGitLink( + checkoutPath: string, + options: LocalWorktreeFilesystemOptions +): Promise { + const { statPath } = getLocalWorktreePathAccess(options) + const stat = (path: string) => statPath(toLocalWorktreeRuntimePath(path, options)) + try { + await stat(checkoutPath) + } catch { + // Git removes a registration whose folder is gone; other errors are Git's to report. + return false + } + try { + await stat(getPathOps(checkoutPath).join(checkoutPath, '.git')) + return false + } catch (error) { + return getErrorCode(error) === 'ENOENT' + } +} + function isRecordedCheckout(worktree: GitWorktreeInfo, record: WorktreeRemovalRecord): boolean { return ( normalizeLocalBranchRef(worktree.branch) === record.branch && From d7d6128eb68921965b72e4bca7cf3c4114ce4b47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:09:01 -0700 Subject: [PATCH 28/31] fix(worktrees): name a worktree Git registers inside a failed delete's folder before asking the user to remove it The refusal for a folder Git no longer tracks tells the user to remove the folder. When that folder holds another worktree Git still registers, doing so would delete that worktree's files too. Delete on the failed row and the startup finish now run the existing nested-worktree check first, so its message ("contains another registered worktree: ") wins, as it did before this branch. --- ...time-failed-local-worktree-removal.test.ts | 9 +++++++-- ...time-interrupted-local-worktree-removal.ts | 2 +- src/main/worktree-failed-removal.test.ts | 15 +++++++++++++++ src/main/worktree-removal-leftover.ts | 19 +++++++++++++++---- src/main/worktree-removal-safety.ts | 2 +- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index e5e84a7cb6ee..8f18044aff93 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -380,14 +380,19 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => interruptedLocalWorktreeRemovalJob(record, jobHost(purged)) ) - await expect(retried).rejects.toThrow(refusal()) + // Named first: removing the folder, as the usual refusal asks, would delete it too. + const nestedRefusal = `Refusing to delete worktree because it contains another registered worktree: ${nested}` + await expect(retried).rejects.toThrow(nestedRefusal) await _settlePendingWorktreeRemovalsForTests() expect(existsSync(join(nested, 'unsaved.txt'))).toBe(true) expect(await isRegistered(nested)).toBe(true) expect(removeHostTree).not.toHaveBeenCalled() expect(purged).toEqual([]) - expect(await listedRows()).toContainEqual({ path: worktreePath, removalError: refusal() }) + expect(await listedRows()).toContainEqual({ path: worktreePath, removalError: nestedRefusal }) + // Delete's own check names it as well, before any job runs. + expect(String(await deleteRow(purged))).toBe(`Error: ${nestedRefusal}`) + expect(existsSync(join(nested, 'unsaved.txt'))).toBe(true) }) it('checks again after the teardown that the folder is still gone', async () => { diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 9b9f0357bdd7..00d2c7792d97 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -179,7 +179,7 @@ async function finishInterruptedLocalWorktreeRemoval( } // Before the teardown, so a refused folder keeps its terminals and watchers. if (!deletable) { - await assertUnregisteredCheckoutGone(record.worktreePath) + await assertUnregisteredCheckoutGone(record.worktreePath, worktrees) } const gate = await args.acquireWatcherRemoval(record.worktreePath) if (args.stopPtys) { diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index 0f8960fd362c..b4e7134e90a8 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -354,6 +354,21 @@ describe('a delete that fails after Git dropped the registration', () => { expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) }) + it('Delete names a worktree Git registers inside the folder, which removing it would delete', async () => { + await failRemoval() + const nested = { ...mainWorktree, path: join(checkout, 'sub'), isMainWorktree: false } + const retry = vi.fn() + + await expect( + retryFailedRemovalUnlessRegistered(worktreeId, checkout, [mainWorktree, nested], retry) + ).rejects.toThrow( + `Refusing to delete worktree because it contains another registered worktree: ${nested.path}` + ) + + expect(retry).not.toHaveBeenCalled() + expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1) + }) + it('Delete finishes the recorded removal once the user removed the folder', async () => { await failRemoval() await rm(checkout, { recursive: true }) diff --git a/src/main/worktree-removal-leftover.ts b/src/main/worktree-removal-leftover.ts index 63bee3d67268..6524ddc5052e 100644 --- a/src/main/worktree-removal-leftover.ts +++ b/src/main/worktree-removal-leftover.ts @@ -11,7 +11,10 @@ import { persistWorktreeRemovalRecords, worktreeCheckoutExists } from './worktree-removal-table' -import { canSafelyRemoveOrphanedWorktreeDirectory } from './worktree-removal-safety' +import { + assertWorktreeDoesNotContainRegisteredWorktree, + canSafelyRemoveOrphanedWorktreeDirectory +} from './worktree-removal-safety' /** * Whether a checkout path Git no longer registers still holds the removed checkout's own leftover: @@ -57,9 +60,17 @@ export function unregisteredFolderRefusal(worktreePath: string): Error { ) } -/** Refuses while anything is at a checkout path Git no longer registers. */ -export async function assertUnregisteredCheckoutGone(worktreePath: string): Promise { +/** + * Refuses while anything is at a checkout path Git no longer registers, naming first any worktree + * in `registeredWorktrees` inside it. + */ +export async function assertUnregisteredCheckoutGone( + worktreePath: string, + registeredWorktrees: readonly Pick[] = [] +): Promise { if (await worktreeCheckoutExists(worktreePath)) { + // Why first: removing the folder, as the refusal asks, would delete that worktree's files too. + assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) throw unregisteredFolderRefusal(worktreePath) } } @@ -84,7 +95,7 @@ export async function retryFailedRemovalUnlessRegistered( } const failed = failedWorktreeRemovals.get(worktreeId) if (failed) { - await assertUnregisteredCheckoutGone(failed.worktreePath) + await assertUnregisteredCheckoutGone(failed.worktreePath, registeredWorktrees) } return retry() !== undefined } diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index 1e284fbb6163..c752d2e21c29 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -115,7 +115,7 @@ export function findRegisteredDeletableWorktree( export function assertWorktreeDoesNotContainRegisteredWorktree( worktreePath: string, - worktrees: readonly GitWorktreeInfo[] + worktrees: readonly Pick[] ): void { const nestedWorktree = worktrees.find((item) => { if (areWorktreePathsEqual(item.path, worktreePath)) { From 7ffe5a32f1d0ac9c9af50ac311322e0c9b8a0e50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:32:30 -0700 Subject: [PATCH 29/31] fix(worktrees): don't prune other worktrees when a delete fails The Git side of a failed delete started with a repo-wide `git worktree prune`. It did nothing for the failed delete, whose admin entry Git had already dropped, but it unregistered any other worktree whose folder was missing at that moment (an unmounted drive), so Orca dropped that workspace too. The failure now runs only the branch step; the prune stays where it was on main, in the folder-gone finish and at startup. --- src/main/git/worktree-removal.ts | 14 +++++++------- ...runtime-failed-local-worktree-removal.test.ts | 16 +++++++++++++++- src/main/worktree-failed-removal.test.ts | 12 ++++++------ src/main/worktree-removal-failure.ts | 6 ++++-- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index d2228e2a4170..77c117083910 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -124,23 +124,23 @@ export async function finishUnregisteredWorktreeRemoval( options: RemoveWorktreeOptions = {} ): Promise { await assertCheckoutGone() - return finishGitSideOfUnregisteredWorktree(repoPath, worktreePath, branch, options) + 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 Git side of a removal once Git no longer registers the checkout, whatever is left on disk: - * stale admin records, then the branch (merged only, as `removeWorktree` does). Deletes no files. + * 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 finishGitSideOfUnregisteredWorktree( +export async function deleteBranchOfUnregisteredWorktree( repoPath: string, worktreePath: string, branch: { name: string; head: string } | null, options: RemoveWorktreeOptions = {} ): Promise { try { - 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 {} } diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 8f18044aff93..10b4b099582b 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -4,7 +4,7 @@ // the file's owner; worktree-failed-removal.test.ts covers the same rules with Git mocked. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -309,6 +309,20 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await git(['worktree', 'list', '--porcelain'])).not.toContain(worktreePath) }) + it('leaves another worktree registered while its folder is missing, as on an unmounted drive', async () => { + const parent = join(scratchDir, 'volume') + const other = join(parent, 'other') + await git(['worktree', 'add', '-q', other, '-b', 'other']) + await writeFile(join(other, 'wip.txt'), 'wip\n') + await rename(parent, join(scratchDir, 'volume-unmounted')) + + expect(String(await failInSession())).toMatch(/Operation not permitted/) + + expect(await isRegistered(other)).toBe(true) + await rename(join(scratchDir, 'volume-unmounted'), parent) + expect(await git(['status', '--porcelain'], other)).toBe('?? wip.txt\n') + }) + it('keeps an unmerged branch when the delete fails, as a normal delete does', async () => { await writeFile(join(worktreePath, 'work.txt'), 'work\n') await git(['add', 'work.txt'], worktreePath) diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index b4e7134e90a8..d37fccdc4e91 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitWorktreeInfo } from '../shared/worktree/types' import { listWorktreesStrict } from './git/worktree' import type * as WorktreeRemovalModule from './git/worktree-removal' -import { finishGitSideOfUnregisteredWorktree } from './git/worktree-removal' +import { deleteBranchOfUnregisteredWorktree } from './git/worktree-removal' import { beginTerminalInstall } from './ipc/watcher-removal-gate' import { registerWorktreeChangeInvalidator } from './ipc/worktree-change-invalidators' import { @@ -35,7 +35,7 @@ import { loadWorktreeRemovalRecordsForStore } from './startup/worktree-removal-r vi.mock('./git/worktree', () => ({ listWorktreesStrict: vi.fn(async () => []) })) vi.mock('./git/worktree-removal', async (importOriginal) => ({ ...(await importOriginal()), - finishGitSideOfUnregisteredWorktree: vi.fn(async () => ({})) + deleteBranchOfUnregisteredWorktree: vi.fn(async () => ({})) })) const GIT_ERROR = "error: failed to delete 'node_modules/a/LICENSE': Operation not permitted" @@ -65,7 +65,7 @@ beforeEach(async () => { afterEach(async () => { _resetPendingWorktreeRemovalsForTests() - vi.mocked(finishGitSideOfUnregisteredWorktree).mockReset() + vi.mocked(deleteBranchOfUnregisteredWorktree).mockReset() vi.restoreAllMocks() await rm(directory, { recursive: true, force: true }) }) @@ -150,7 +150,7 @@ describe('a delete that fails after Git dropped the registration', () => { expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([]) // Git still has the checkout, so its branch and remote stay with it. - expect(finishGitSideOfUnregisteredWorktree).not.toHaveBeenCalled() + expect(deleteBranchOfUnregisteredWorktree).not.toHaveBeenCalled() expect(cleanupPushTargetRemote).not.toHaveBeenCalled() }) @@ -159,7 +159,7 @@ describe('a delete that fails after Git dropped the registration', () => { await expect(startFailingRemoval({ cleanupPushTargetRemote })).rejects.toThrow(GIT_ERROR) await _settlePendingWorktreeRemovalsForTests() - expect(finishGitSideOfUnregisteredWorktree).toHaveBeenCalledWith('/work/repo', checkout, { + expect(deleteBranchOfUnregisteredWorktree).toHaveBeenCalledWith('/work/repo', checkout, { name: 'feature', head: 'abc' }) @@ -169,7 +169,7 @@ describe('a delete that fails after Git dropped the registration', () => { }) it('keeps the failed row and its error when that bookkeeping fails', async () => { - vi.mocked(finishGitSideOfUnregisteredWorktree).mockRejectedValue(new Error('branch locked')) + vi.mocked(deleteBranchOfUnregisteredWorktree).mockRejectedValue(new Error('branch locked')) const cleanupPushTargetRemote = vi.fn(async () => { throw new Error('remote busy') }) diff --git a/src/main/worktree-removal-failure.ts b/src/main/worktree-removal-failure.ts index 99c2ee1842b2..905d1139ca87 100644 --- a/src/main/worktree-removal-failure.ts +++ b/src/main/worktree-removal-failure.ts @@ -1,4 +1,4 @@ -import { finishGitSideOfUnregisteredWorktree } from './git/worktree-removal' +import { deleteBranchOfUnregisteredWorktree } from './git/worktree-removal' import { isCheckoutRegistered, isUnregisteredRemovalLeftover } from './worktree-removal-leftover' import type { WorktreeRemovalFailure, WorktreeRemovalRecord } from './worktree-removal-records' import { worktreeCheckoutExists } from './worktree-removal-table' @@ -34,7 +34,9 @@ async function finishGitSideOfFailedRemoval( ): Promise { // Why caught: bookkeeping; the failed row and the request's error stand either way. try { - await finishGitSideOfUnregisteredWorktree( + // No prune: Git already dropped this entry; a repo-wide prune would unregister any other + // worktree whose folder is missing for now (an unmounted drive). + await deleteBranchOfUnregisteredWorktree( record.repoPath, record.worktreePath, record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null From 487aa1332ca278a5b6834f279963adb5e967e3b6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:35:30 -0700 Subject: [PATCH 30/31] test(worktrees): cover the push-target cleanup every delete job wires for a failed delete A failed delete removes the push-target remote Orca added through the hook each job creator passes: desktop Delete, runtime Delete, and the startup finish. Nothing tested that wiring; these tests fail when any creator drops it. --- src/main/ipc/worktrees-failed-removal.test.ts | 34 ++++++- ...time-failed-local-worktree-removal.test.ts | 91 +++++++++++++++++-- 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index b461f39bd697..be2153d283c3 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -3,12 +3,13 @@ // 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' @@ -167,6 +168,37 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { 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 () => { await failAfterGitDroppedIt() diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 10b4b099582b..146757497fb9 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -10,6 +10,7 @@ import { join } from 'node:path' import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' +import type { GitPushTarget } from '../../shared/worktree/types' import { removeTree } from '../../shared/windows-transient-lock-removal' import type { Store } from '../persistence' import type * as HostTreeRemoval from '../host-tree-removal' @@ -34,6 +35,8 @@ import { type WorktreeRemovalRecord } from '../worktree-removal-records' import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' +import { removeRuntimeRegisteredLocalWorktree } from './runtime-registered-local-worktree-removal' +import type { RuntimeStore } from './runtime-store-contract' vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: () => ({}) @@ -74,14 +77,34 @@ async function listedRows(): Promise<{ path: string; removalError?: string }[]> .map(({ path, removalError }) => ({ path, ...(removalError ? { removalError } : {}) })) } -function jobHost(purged: string[], stopPtys = vi.fn(async () => {})) { +/** Reads only repos and worktree metadata, as the finish and the push-target cleanup do. */ +function storeStub(pushTarget?: GitPushTarget) { + const meta = pushTarget ? { pushTarget } : undefined return { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the finish reads only repos and worktree metadata from the store here; git options are mocked and no push target is set. - store: { - getRepo: (id: string) => (id === repo.id ? repo : undefined), - getRepos: () => [repo], - getWorktreeMeta: () => undefined - } as unknown as Store, + getRepo: (id: string) => (id === repo.id ? repo : undefined), + getRepos: () => [repo], + getWorktreeMeta: (id: string) => (id === worktreeId ? meta : undefined), + getAllWorktreeMeta: () => (meta ? { [worktreeId]: meta } : {}) + } +} + +/** A remote Orca added for a fork PR's push target, as worktree create does. */ +async function addOrcaPushTargetRemote(): Promise { + const pushTarget = { + remoteName: 'pr-contributor', + branchName: 'feature', + remoteUrl: 'https://github.com/contributor/repo.git', + remoteCreated: true + } + await git(['remote', 'add', pushTarget.remoteName, pushTarget.remoteUrl]) + await git(['config', `remote.${pushTarget.remoteName}.orca-created`, 'true']) + return pushTarget +} + +function jobHost(purged: string[], stopPtys = vi.fn(async () => {}), pushTarget?: GitPushTarget) { + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the finish reads only repos and worktree metadata from the store here; git options are mocked. + store: storeStub(pushTarget) as unknown as Store, acquireWatcherRemoval: async (path: string) => { const gate = acquireWatcherRemovalGate(path) return { finish: async () => gate.release() } @@ -123,7 +146,7 @@ function failStartupFinish(): Promise { } /** Resumes a recorded delete of the checkout as the next start does; resolves with its error. */ -async function finishAtStartup(purged: string[]): Promise { +async function finishAtStartup(purged: string[], pushTarget?: GitPushTarget): Promise { const record: WorktreeRemovalRecord = { worktreeId, repoId: repo.id, @@ -139,7 +162,7 @@ async function finishAtStartup(purged: string[]): Promise { await loadWorktreeRemovalRecords(recordsDir) const joined = waitForPendingWorktreeRemoval(worktreeId)! resumeInterruptedWorktreeRemovals((interrupted) => - interruptedLocalWorktreeRemovalJob(interrupted, jobHost(purged)) + interruptedLocalWorktreeRemovalJob(interrupted, jobHost(purged, undefined, pushTarget)) ) const error = await joined.then( () => undefined, @@ -323,6 +346,56 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part expect(await git(['status', '--porcelain'], other)).toBe('?? wip.txt\n') }) + it('removes the push-target remote Orca added when a runtime delete fails', async () => { + const pushTarget = await addOrcaPushTargetRemote() + const worktree = (await listWorktreesStrict(repoPath)).find((row) => + areWorktreePathsEqual(row.path, worktreePath) + )! + const accepted = await removeRuntimeRegisteredLocalWorktree({ + repo, + target: { id: worktreeId, repoId: repo.id, path: worktreePath, pushTarget }, + registeredWorktree: worktree, + removedPushTarget: pushTarget, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the delete reads only repos and worktree metadata from the store here. + store: storeStub(pushTarget) as unknown as RuntimeStore, + localOptions: {}, + hasLocalOptions: false, + force: true, + runHooks: false, + allowFailedArchiveHook: false, + allowUnverifiedPtyStop: false, + deleteBranch: true, + acquireWatcherRemoval: async (path) => { + const gate = acquireWatcherRemovalGate(path) + return { finish: async () => gate.release() } + }, + stopPtys: async () => {}, + closeWatchers: async () => {}, + preserveBranchHead: (result) => result ?? {}, + finishRemoval: () => {}, + onRemoved: () => {}, + publish: () => {} + }) + expect(accepted).toMatchObject({ removing: true }) + const error = await waitForPendingWorktreeRemoval(worktreeId)?.catch( + (reason: unknown) => reason + ) + await _settlePendingWorktreeRemovalsForTests() + + expect(String(error)).toMatch(/Operation not permitted/) + expect(await git(['remote'])).not.toContain(pushTarget.remoteName) + expect(existsSync(lockedFile)).toBe(true) + }) + + it('removes the push-target remote Orca added when the startup finish fails', async () => { + const pushTarget = await addOrcaPushTargetRemote() + + expect(String(await finishAtStartup([], pushTarget))).toMatch(/Operation not permitted/) + + expect(await git(['remote'])).not.toContain(pushTarget.remoteName) + expect(existsSync(lockedFile)).toBe(true) + }) + it('keeps an unmerged branch when the delete fails, as a normal delete does', async () => { await writeFile(join(worktreePath, 'work.txt'), 'work\n') await git(['add', 'work.txt'], worktreePath) From 0cc465e0e4dcf9a1daaf6127ccda6c03ffbce022 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:32:22 -0700 Subject: [PATCH 31/31] fix(worktrees): say the leftover folder was left on disk instead of what Orca won't do --- src/main/ipc/worktrees-failed-removal.test.ts | 2 +- .../orca-runtime-tests/worktree-removal-failed-retry.spec.ts | 2 +- .../runtime/runtime-failed-local-worktree-removal.test.ts | 2 +- .../runtime-interrupted-local-worktree-removal.test.ts | 4 ++-- src/main/worktree-failed-removal.test.ts | 2 +- src/main/worktree-removal-leftover.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index be2153d283c3..1090c9338e58 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -213,7 +213,7 @@ describe('a failed delete Git no longer registers, over desktop IPC', () => { for (const force of [false, true]) { await expect(remove({ worktreeId: featureId, force })).rejects.toThrow( - "Git no longer tracks /workspace/feature-wt, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list." + '/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() diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts index 0feae3a2a198..b44a84d748d0 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-failed-retry.spec.ts @@ -88,7 +88,7 @@ describe('runtime Delete on a failed delete’s leftover', () => { waitForBackgroundRemoval: false }) ).rejects.toThrow( - `Git no longer tracks ${leftover}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` + `${leftover} is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace.` ) } await _settlePendingWorktreeRemovalsForTests() diff --git a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts index 146757497fb9..dcd6d8d22fbd 100644 --- a/src/main/runtime/runtime-failed-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-failed-local-worktree-removal.test.ts @@ -138,7 +138,7 @@ async function deleteRow(purged: string[], stopPtys = vi.fn(async () => {})): Pr } const refusal = (): string => - `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` + `${worktreePath} is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace.` /** A delete a quit interrupted, finished at the next start, where Git fails on the locked file. */ function failStartupFinish(): Promise { diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts index c2997e02085b..d2b5be461c4c 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -219,7 +219,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { const { outcome, purged, records } = await finishAfterRestart({ force }) - const refusal = `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` + const refusal = `${worktreePath} is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace.` expect(outcome).toEqual({ status: 'failed', error: `Error: ${refusal}` }) expect(existsSync(join(worktreePath, '.git'))).toBe(false) expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) @@ -261,7 +261,7 @@ describe('finishing an interrupted worktree removal after a restart', () => { const { outcome, purged, records } = await finishAfterRestart({ force: true }) - const refusal = `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` + const refusal = `${worktreePath} is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace.` expect(outcome).toEqual({ status: 'failed', error: `Error: ${refusal}` }) expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n') expect(removeHostTree).not.toHaveBeenCalled() diff --git a/src/main/worktree-failed-removal.test.ts b/src/main/worktree-failed-removal.test.ts index d37fccdc4e91..bf88a8b0566a 100644 --- a/src/main/worktree-failed-removal.test.ts +++ b/src/main/worktree-failed-removal.test.ts @@ -345,7 +345,7 @@ describe('a delete that fails after Git dropped the registration', () => { await expect( retryFailedRemovalUnlessRegistered(worktreeId, checkout, [mainWorktree], retry) ).rejects.toThrow( - `Git no longer tracks ${checkout}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` + `${checkout} is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace.` ) expect(retry).not.toHaveBeenCalled() diff --git a/src/main/worktree-removal-leftover.ts b/src/main/worktree-removal-leftover.ts index 6524ddc5052e..0582368becf5 100644 --- a/src/main/worktree-removal-leftover.ts +++ b/src/main/worktree-removal-leftover.ts @@ -56,7 +56,7 @@ export async function isCheckoutRegistered(record: { */ export function unregisteredFolderRefusal(worktreePath: string): Error { return new Error( - `Git no longer tracks ${worktreePath}, so Orca won't delete it. Remove the folder yourself, and Orca will drop this workspace from the list.` + `${worktreePath} is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace.` ) }