Files
orca/src/main/worktree-failed-removal.test.ts
T
Brennan Benson 38255300d9 fix(worktrees): never delete a folder git no longer tracks (#25075)
* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* test(worktrees): the desktop Delete test's stand-in host ends the workspace without a cast

* fix(worktrees): a listing asks Git before dropping a failed delete, and ends its workspace in the same step

* fix(worktrees): a failed delete Git cannot be asked about stays failed for the next listing to decide

* test(worktrees): the failed-delete record test states that Git still registers the checkout

* fix(worktrees): a restart keeps a failed delete whose checkout is gone, so the first listing ends its workspace

* fix(worktrees): a folder Orca cannot read keeps its failed delete and is never deleted

* fix(worktrees): a failed delete leaves the disk only after its workspace's end is saved

* fix(worktrees): a runtime Delete of a failed delete's row is told when Orca left the folder in place

* test(worktrees): the desktop Delete test's stand-in host returns nothing

* fix(worktrees): a Delete of a failed delete's leftover whose .git Orca cannot read says so

* Revert "fix(worktrees): a runtime Delete of a failed delete's row is told when Orca left the folder in place"

This reverts commit 68d65989de.

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).

* fix(worktrees): a failed delete Git could not be asked about is dropped once Git says it still registers the checkout

* revert to main before the standard-model rework

* 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.

* 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
  <path>, 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.

* test(worktrees): read Git's listing before the Delete call in the real-Git failed-delete suite

* docs(worktrees): say when a failed delete's record ends under the Git-only delete

* 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 <path>, 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.

* 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.

* 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.

* 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: <path>") wins, as it did before this branch.

* 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.

* 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.

* fix(worktrees): say the leftover folder was left on disk instead of what Orca won't do
2026-10-14 10:58:27 -07:00

384 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// A delete that fails after Git dropped the checkout's registration: the leftover stays listed with
// 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'
import { tmpdir } from 'node:os'
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 { deleteBranchOfUnregisteredWorktree } from './git/worktree-removal'
import { beginTerminalInstall } from './ipc/watcher-removal-gate'
import { registerWorktreeChangeInvalidator } from './ipc/worktree-change-invalidators'
import {
_resetPendingWorktreeRemovalsForTests,
_settlePendingWorktreeRemovalsForTests,
loadWorktreeRemovalRecords,
resumeInterruptedWorktreeRemovals,
retryFailedWorktreeRemoval,
startBackgroundWorktreeRemoval,
waitForPendingWorktreeRemoval,
type BackgroundWorktreeRemovalJob
} from './worktree-background-removal'
import {
projectPendingWorktreeRemovals,
snapshotPendingWorktreeRemovals,
withUnregisteredRemovalCheckouts
} from './worktree-removal-listing'
import { retryFailedRemovalUnlessRegistered } from './worktree-removal-leftover'
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<typeof WorktreeRemovalModule>()),
deleteBranchOfUnregisteredWorktree: vi.fn(async () => ({}))
}))
const GIT_ERROR = "error: failed to delete 'node_modules/a/LICENSE': Operation not permitted"
let directory = ''
let checkout = ''
let worktreeId = ''
const mainWorktree: GitWorktreeInfo = {
path: '/work/repo',
head: 'abc',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
beforeEach(async () => {
directory = await realpath(await mkdtemp(join(tmpdir(), 'orca-failed-removal-')))
checkout = join(directory, 'feature')
worktreeId = `repo-1::${checkout}`
// What Git left: part of the checkout, `.git` already deleted.
await mkdir(join(checkout, 'node_modules', 'a'), { recursive: true })
await writeFile(join(checkout, 'node_modules', 'a', 'LICENSE'), 'MIT\n')
await mkdir(join(directory, 'profile'))
await loadWorktreeRemovalRecords(join(directory, 'profile'))
vi.mocked(listWorktreesStrict).mockResolvedValue([mainWorktree])
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(async () => {
_resetPendingWorktreeRemovalsForTests()
vi.mocked(deleteBranchOfUnregisteredWorktree).mockReset()
vi.restoreAllMocks()
await rm(directory, { recursive: true, force: true })
})
function startFailingRemoval(
job: Pick<BackgroundWorktreeRemovalJob, 'cleanupPushTargetRemote'> = {}
): Promise<unknown> {
return startBackgroundWorktreeRemoval({
removal: {
worktreeId,
repoId: 'repo-1',
repoPath: '/work/repo',
worktree: { path: checkout, branch: 'refs/heads/feature', head: 'abc' },
deleteBranch: true,
force: true
},
run: async () => {
throw new Error(GIT_ERROR)
},
publish: () => {},
...job
})
}
async function failRemoval(): Promise<void> {
await expect(startFailingRemoval()).rejects.toThrow(GIT_ERROR)
await _settlePendingWorktreeRemovalsForTests()
}
async function listRows(): Promise<GitWorktreeInfo[]> {
return withUnregisteredRemovalCheckouts('repo-1', [mainWorktree])
}
const leftoverRow = (): GitWorktreeInfo => ({
path: checkout,
head: 'abc',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false,
removalError: GIT_ERROR
})
describe('a delete that fails after Git dropped the registration', () => {
it('keeps the leftover listed with the error, recorded on disk, and not pending', async () => {
await failRemoval()
expect(await listRows()).toEqual([mainWorktree, leftoverRow()])
const [record] = await readWorktreeRemovalRecords(join(directory, 'profile'))
expect(record).toMatchObject({ worktreeId, failure: { message: GIT_ERROR } })
expect(waitForPendingWorktreeRemoval(worktreeId)).toBeUndefined()
// Not marked removing and not left out for older clients: it is a row they can delete again.
const rows: { id: string; hostId?: undefined }[] = [{ id: worktreeId }]
expect(
projectPendingWorktreeRemovals(
rows,
(row) => row.id,
false,
snapshotPendingWorktreeRemovals()
)
).toEqual(rows)
// Nothing fences the leftover: a failed delete must not block terminals indefinitely.
beginTerminalInstall(checkout)()
})
it('invalidates cached listings, which still hold the registration Git dropped', async () => {
const invalidated = vi.fn()
const unregister = registerWorktreeChangeInvalidator(invalidated)
await failRemoval()
unregister()
expect(invalidated).toHaveBeenCalledWith('repo-1')
})
it('clears the record as before when Git still registers the checkout', async () => {
vi.mocked(listWorktreesStrict).mockResolvedValue([
mainWorktree,
{ ...leftoverRow(), removalError: undefined }
])
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(deleteBranchOfUnregisteredWorktree).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(deleteBranchOfUnregisteredWorktree).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(deleteBranchOfUnregisteredWorktree).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 () => {
await rm(checkout, { recursive: true })
await failRemoval()
expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([])
})
it('never retries it on its own, at startup or when interrupted removals resume', async () => {
await failRemoval()
_resetPendingWorktreeRemovalsForTests()
await loadWorktreeRemovalRecords(join(directory, 'profile'))
const jobFor = vi.fn()
resumeInterruptedWorktreeRemovals(jobFor)
expect(jobFor).not.toHaveBeenCalled()
expect(waitForPendingWorktreeRemoval(worktreeId)).toBeUndefined()
expect(await listRows()).toEqual([mainWorktree, leftoverRow()])
beginTerminalInstall(checkout)()
})
it('runs the recorded removal again on Delete and clears the record once it succeeds', async () => {
await failRemoval()
const publish = vi.fn()
const run = vi.fn(async () => {
// The retry shows as removing while it runs.
expect(await listRows()).toEqual([
mainWorktree,
{ ...leftoverRow(), removalError: undefined }
])
await rm(checkout, { recursive: true })
return {}
})
const retried = retryFailedWorktreeRemoval(worktreeId, 'local', (record) => {
// The user's first choices, without the failure.
expect(record).toMatchObject({ deleteBranch: true, force: true })
expect(record).not.toHaveProperty('failure')
return { run, publish }
})
// A second window's Delete joins the same run.
expect(waitForPendingWorktreeRemoval(worktreeId)).toBe(retried)
await expect(retried).resolves.toEqual({})
await _settlePendingWorktreeRemovalsForTests()
expect(run).toHaveBeenCalledTimes(1)
expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([])
expect(await listRows()).toEqual([mainWorktree])
expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined()
})
it('keeps the row with the new error when the retry fails the same way', async () => {
await failRemoval()
const retried = retryFailedWorktreeRemoval(worktreeId, undefined, () => ({
run: async () => {
throw new Error('still not permitted')
},
publish: () => {}
}))
await expect(retried).rejects.toThrow('still not permitted')
await _settlePendingWorktreeRemovalsForTests()
expect(await listRows()).toEqual([
mainWorktree,
{ ...leftoverRow(), removalError: 'still not permitted' }
])
})
it('does not run the recorded removal once Git registers a checkout at the path again', async () => {
await failRemoval()
vi.mocked(listWorktreesStrict).mockResolvedValue([
mainWorktree,
{ ...leftoverRow(), removalError: undefined }
])
const run = vi.fn(async () => ({}))
const retried = retryFailedWorktreeRemoval(worktreeId, 'local', () => ({
run,
publish: () => {}
}))
await expect(retried).rejects.toThrow(/A different checkout is now at/)
await _settlePendingWorktreeRemovalsForTests()
expect(run).not.toHaveBeenCalled()
expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([])
})
it('is not retried for another host', async () => {
await failRemoval()
expect(retryFailedWorktreeRemoval(worktreeId, 'ssh:box', vi.fn())).toBeUndefined()
})
it('ends at the next listing once the checkout is deleted outside Orca', async () => {
await failRemoval()
await rm(checkout, { recursive: true })
expect(await listRows()).toEqual([mainWorktree])
await vi.waitFor(async () =>
expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([])
)
})
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'))).toEqual([])
})
it('ends at startup once its repo is removed from Orca, leaving the files', async () => {
await failRemoval()
_resetPendingWorktreeRemovalsForTests()
// Only an SSH copy of the project is left under the same repo id.
await loadWorktreeRemovalRecordsForStore({
getProfileStorageDirectory: () => join(directory, 'profile'),
getRepos: () => [{ id: 'repo-1', connectionId: 'box', executionHostId: null }]
})
expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([])
expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined()
expect(await readdir(checkout)).toEqual(['node_modules'])
})
it('is kept at startup while the repo’s local copy is still in Orca', async () => {
await failRemoval()
_resetPendingWorktreeRemovalsForTests()
await loadWorktreeRemovalRecordsForStore({
getProfileStorageDirectory: () => join(directory, 'profile'),
getRepos: () => [
{ id: 'repo-1', connectionId: 'box', executionHostId: null },
{ id: 'repo-1', connectionId: null, executionHostId: null }
]
})
expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toHaveLength(1)
expect(await listRows()).toHaveLength(2)
})
it('ends at the next listing once a different checkout takes the path', async () => {
await failRemoval()
await mkdir(join(checkout, '.git'))
expect(await listRows()).toEqual([mainWorktree])
expect(retryFailedWorktreeRemoval(worktreeId, 'local', vi.fn())).toBeUndefined()
await vi.waitFor(async () =>
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(
`${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()
expect(await readdir(checkout)).toEqual(['node_modules'])
expect(await listRows()).toEqual([mainWorktree, leftoverRow()])
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 })
const retry = vi.fn(() => Promise.resolve({}))
await expect(
retryFailedRemovalUnlessRegistered(worktreeId, checkout, [mainWorktree], retry)
).resolves.toBe(true)
expect(retry).toHaveBeenCalledTimes(1)
})
})