mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(windows): delete again succeeds after a held file is released (#34058)
* fix(windows): stop recursive deletes from retry-storming when a file is held open Node's async recursive rm retries every nested entry separately and orphans sibling walkers when it rejects, so deleting a workspace holding a live browser profile multiplied filesystem work per directory level until the main process hung. Delete Windows host trees with a walker that retries whole passes from one shared budget and leaves nothing running on failure. Also bound and log detached worktree removal, and fail a file transaction whose proper-lockfile lock is compromised instead of throwing from its timer. * fix(windows): retry a locked delete as whole passes instead of a custom walker Replace the hand-rolled tree walker with Node's own recursive rm and no per-entry retries. Async rm gives each nested entry its own retry counter and never stops siblings, which is what multiplied the work; with retries off each pass is one bounded walk, and a transient Windows lock repeats the whole pass from the existing 250/500/1000/2000 ms budget, checking the job's signal between passes. A failed delete now names the file another program holds open instead of Node's EBUSY/EPERM text. Drop the 30-minute background deadline. The lost-lock error no longer embeds the library's message and says the write may or may not have been saved. * test: cover bounded deletion failure and recovery contracts * test: prune unchanged cleanup coverage * fix(worktrees): give truthful failed-delete cleanup advice * test: document fake-clock retry adapter * fix(windows): preserve checkout ownership until files are removed * fix: preserve worktree ownership checks across metadata aliases * test: make checkout retry clock completion explicit * Keep failed Windows workspace deletions retryable * test: correct removal fixture imports and path formatting * fix: retain failed removal until a retry is accepted * test: identify the mocked backoff clock * test: preserve failed removal records and recreate marker fixtures * test(worktrees): preserve exact checkout bytes in refusal contract * test(worktrees): exercise current native deletion contracts * test(windows): prove long-path deletion through native checkout removal * test(windows): preserve real Git runner exports in removal fixture --------- Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
co-authored by
Orca Worker
parent
a588c8ce66
commit
f0eb0949d0
@@ -1,25 +1,27 @@
|
||||
// Electron treats .asar files as directories; unpatched fs prevents recursive rm failures.
|
||||
// Avoid process.noAsar: other main-process work may load app.asar during a long removal.
|
||||
|
||||
import { rm as nodeRm } from 'node:fs/promises'
|
||||
import * as nodeFs from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
type Rm = typeof nodeRm
|
||||
let resolvedFs: typeof nodeFs | undefined
|
||||
|
||||
let resolvedRm: Rm | undefined
|
||||
|
||||
function resolveRm(): Rm {
|
||||
try {
|
||||
// Resolve lazily: original-fs exists only in Electron; plain Node’s fs is already unpatched.
|
||||
const originalFs = createRequire(__filename)('original-fs') as { promises?: { rm?: Rm } }
|
||||
return typeof originalFs.promises?.rm === 'function' ? originalFs.promises.rm : nodeRm
|
||||
} catch {
|
||||
return nodeRm
|
||||
function nativeFs(): typeof nodeFs {
|
||||
if (!resolvedFs) {
|
||||
try {
|
||||
// Resolve lazily: original-fs exists only in Electron; plain Node's fs is already unpatched.
|
||||
const originalFs: { promises?: typeof nodeFs } = createRequire(__filename)('original-fs')
|
||||
resolvedFs = originalFs.promises ?? nodeFs
|
||||
} catch {
|
||||
resolvedFs = nodeFs
|
||||
}
|
||||
}
|
||||
return resolvedFs
|
||||
}
|
||||
|
||||
/** `fs.promises.rm` that sees a `*.asar` as the file it is rather than as a directory. */
|
||||
export const rm: Rm = (path, options) => {
|
||||
resolvedRm ??= resolveRm()
|
||||
return resolvedRm(path, options)
|
||||
}
|
||||
export const rm: typeof nodeFs.rm = (path, options) => nativeFs().rm(path, options)
|
||||
export const readdir = (path: string): Promise<string[]> => nativeFs().readdir(path)
|
||||
export const unlink = (path: string): Promise<void> => nativeFs().unlink(path)
|
||||
export const rmdir = (path: string): Promise<void> => nativeFs().rmdir(path)
|
||||
export const lstat = (path: string) => nativeFs().lstat(path)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { join, resolve } from 'node:path'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { lstat, readdir } from '../asar-transparent-fs'
|
||||
import { removeHostCheckout } from '../host-tree-removal'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard'
|
||||
import {
|
||||
assertWorktreeUnlockedForRemoval,
|
||||
unregisteredFolderRefusal
|
||||
} from '../../shared/worktree/removal'
|
||||
import {
|
||||
assertWorktreeDoesNotContainRegisteredWorktree,
|
||||
canSafelyRemoveOrphanedWorktreeDirectory,
|
||||
findRegisteredDeletableWorktree,
|
||||
isDangerousWorktreeRemovalPath,
|
||||
ORPHANED_WORKTREE_DIRECTORY_MESSAGE
|
||||
} from '../worktree-removal-safety'
|
||||
import { readTranslatedWorktreeGraph } from './worktree-list-reader'
|
||||
import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight'
|
||||
import { getErrorCode, type RemoveWorktreeOptions } from './worktree-operation-options'
|
||||
|
||||
export function usesNativeWindowsRemoval(path: string, options: { wslDistro?: string }): boolean {
|
||||
return process.platform === 'win32' && !options.wslDistro && !parseWslPath(path)
|
||||
}
|
||||
|
||||
export async function nativeWindowsMarkerMissing(
|
||||
path: string,
|
||||
options: { wslDistro?: string }
|
||||
): Promise<boolean> {
|
||||
if (!usesNativeWindowsRemoval(path, options)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await lstat(join(path, '.git'))
|
||||
return false
|
||||
} catch (error) {
|
||||
if (getErrorCode(error) === 'ENOENT') {
|
||||
return true
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Called inside the destructive slot, after teardown; every pass rederives authority from Git/disk. */
|
||||
export async function removeWindowsCheckout(
|
||||
repo: string,
|
||||
root: string,
|
||||
force: boolean,
|
||||
options: RemoveWorktreeOptions
|
||||
): Promise<void> {
|
||||
await removeHostCheckout(
|
||||
root,
|
||||
(checkClean) => authorizeWindowsCheckout(repo, root, force, options, false, checkClean),
|
||||
options.checkoutDeleteSignal
|
||||
)
|
||||
}
|
||||
|
||||
/** Share ordinary refusal before either entrypoint publishes a retry or tears down resources. */
|
||||
export async function authorizeUnregisteredWindowsRemoval(
|
||||
registered: boolean,
|
||||
repo: string,
|
||||
root: string,
|
||||
force: boolean,
|
||||
options: RemoveWorktreeOptions
|
||||
): Promise<void> {
|
||||
if (!registered && usesNativeWindowsRemoval(root, options)) {
|
||||
await authorizeWindowsCheckout(
|
||||
repo,
|
||||
root,
|
||||
force,
|
||||
{ ...options, knownRemovedWorktree: null },
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only eligibility before teardown; destructive admission always repeats it after waits. */
|
||||
export async function authorizeWindowsCheckout(
|
||||
repo: string,
|
||||
root: string,
|
||||
force: boolean,
|
||||
options: RemoveWorktreeOptions & Parameters<typeof assertWorktreeCleanForRemoval>[2],
|
||||
beforeTeardown = false,
|
||||
checkClean = true
|
||||
): Promise<boolean> {
|
||||
const worktrees = await readTranslatedWorktreeGraph(repo, options)
|
||||
const current = findRegisteredDeletableWorktree(repo, root, worktrees, CLIENT_REMOVAL_HOME)
|
||||
if (isDangerousWorktreeRemovalPath(root, repo, CLIENT_REMOVAL_HOME)) {
|
||||
throw new Error(`Refusing to delete protected worktree path: ${root}`)
|
||||
}
|
||||
// The unregistered route needs the same fresh nested-worktree refusal.
|
||||
if (!current) {
|
||||
assertWorktreeDoesNotContainRegisteredWorktree(root, worktrees)
|
||||
}
|
||||
assertWorktreeUnlockedForRemoval(current ?? undefined)
|
||||
const expected = options.knownRemovedWorktree
|
||||
if (
|
||||
current &&
|
||||
(expected === null ||
|
||||
(expected &&
|
||||
(current.branch !== expected.branch || (expected.head && current.head !== expected.head))))
|
||||
) {
|
||||
throw new Error(
|
||||
`A different checkout is now at ${root}; Orca left it in place. Delete it again to remove it.`
|
||||
)
|
||||
}
|
||||
let entry
|
||||
try {
|
||||
entry = await lstat(root)
|
||||
} catch (error) {
|
||||
if (getErrorCode(error) === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to delete unregistered worktree path: ${root}`)
|
||||
}
|
||||
if (await nativeWindowsMarkerMissing(root, options)) {
|
||||
// Reject only; an empty observation never authorizes recursive deletion after teardown.
|
||||
if (beforeTeardown && (await readdir(root)).length > 0) {
|
||||
throw unregisteredFolderRefusal(root)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!(await canSafelyRemoveOrphanedWorktreeDirectory(root, repo, CLIENT_REMOVAL_HOME))) {
|
||||
throw new Error(`Refusing to delete unregistered worktree path: ${root}`)
|
||||
}
|
||||
if (!current && !force) {
|
||||
throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE)
|
||||
}
|
||||
const marker = join(root, '.git')
|
||||
const admin = resolve(
|
||||
root,
|
||||
(await readFile(marker, 'utf8'))
|
||||
.split(/\r?\n/, 1)[0]
|
||||
.trim()
|
||||
.replace(/^gitdir:\s*/i, '')
|
||||
)
|
||||
try {
|
||||
await lstat(join(admin, 'locked'))
|
||||
throw new Error('Cannot remove a locked worktree. Unlock it before deleting.')
|
||||
} catch (error) {
|
||||
if (getErrorCode(error) !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (checkClean && !force) {
|
||||
await assertWorktreeCleanForRemoval(root, force, options, admin)
|
||||
return authorizeWindowsCheckout(repo, root, force, options, beforeTeardown, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export type AddWorktreeOptions = GitWorktreeExecOptions & {
|
||||
export type RemoveWorktreeOptions = GitWorktreeExecOptions & {
|
||||
deleteBranch?: boolean
|
||||
forceBranchDelete?: boolean
|
||||
knownRemovedWorktree?: Pick<GitWorktreeInfo, 'branch' | 'head' | 'locked' | 'lockReason'>
|
||||
knownRemovedWorktree?: Pick<GitWorktreeInfo, 'branch' | 'head' | 'locked' | 'lockReason'> | null
|
||||
/** Stops only `git worktree remove`; a killed branch cleanup could strand a ref lock. */
|
||||
checkoutDeleteSignal?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { isWorktreePathMissing } from '../worktree-removal-safety'
|
||||
import { SUBMODULE_DIRTY_REMOVAL_DETAIL } from '../../shared/worktree/removal'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import type { WorktreeRemovalPreflightOptions } from './worktree-operation-options'
|
||||
@@ -8,13 +9,38 @@ import { WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS, gitExecOptions } from './worktre
|
||||
export async function assertWorktreeCleanForRemoval(
|
||||
worktreePath: string,
|
||||
force = false,
|
||||
options: WorktreeRemovalPreflightOptions = {}
|
||||
options: WorktreeRemovalPreflightOptions = {},
|
||||
adminDirectory?: string
|
||||
): Promise<void> {
|
||||
if (force) {
|
||||
return
|
||||
}
|
||||
|
||||
const { ignoredUntrackedPaths = [], ...gitOptions } = options
|
||||
const bound = adminDirectory
|
||||
? { env: { ...process.env, GIT_DIR: join(worktreePath, '.git'), GIT_WORK_TREE: worktreePath } }
|
||||
: {}
|
||||
const execution = {
|
||||
...gitExecOptions(worktreePath, gitOptions),
|
||||
...bound,
|
||||
timeout: gitOptions.timeout ?? WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS
|
||||
}
|
||||
if (adminDirectory) {
|
||||
const { stdout: index } = await gitExecFileAsync(['ls-files', '--stage', '-z'], execution)
|
||||
if (!(await isWorktreePathMissing(join(adminDirectory, 'modules')))) {
|
||||
throw new Error(SUBMODULE_DIRTY_REMOVAL_DETAIL)
|
||||
}
|
||||
for (const entry of index.split('\0')) {
|
||||
if (
|
||||
entry.startsWith('160000 ') &&
|
||||
!(await isWorktreePathMissing(
|
||||
join(worktreePath, entry.slice(entry.indexOf('\t') + 1), '.git')
|
||||
))
|
||||
) {
|
||||
throw new Error(SUBMODULE_DIRTY_REMOVAL_DETAIL)
|
||||
}
|
||||
}
|
||||
}
|
||||
const useNullTerminatedStatus = ignoredUntrackedPaths.length > 0
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
[
|
||||
@@ -25,10 +51,7 @@ export async function assertWorktreeCleanForRemoval(
|
||||
// User ignore-submodules config must not hide edits from the dirty check.
|
||||
'--ignore-submodules=none'
|
||||
],
|
||||
{
|
||||
...gitExecOptions(worktreePath, gitOptions),
|
||||
timeout: gitOptions.timeout ?? WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS
|
||||
}
|
||||
execution
|
||||
)
|
||||
// Use the same entries for the verdict and the error message.
|
||||
const blockingEntries = useNullTerminatedStatus
|
||||
|
||||
@@ -6,9 +6,18 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as nativeFs from '../asar-transparent-fs'
|
||||
import * as diskFs from 'node:fs/promises'
|
||||
import * as runner from './runner'
|
||||
import {
|
||||
runUnderWorktreeDeleteLimit,
|
||||
_worktreeDeleteLimitSnapshotForTests
|
||||
} from './worktree-delete-limit'
|
||||
import { installRealWorkerThreadChildProcesses } from '../worker-thread-child-process/worker-thread-child-process-real.test-fixtures'
|
||||
import { removeTree } from '../../shared/windows-transient-lock-removal'
|
||||
import { canSafelyRemoveOrphanedWorktreeDirectory } from '../worktree-removal-safety'
|
||||
import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard'
|
||||
import { classifyWorktreeForceDeleteReason } from '../../shared/worktree/removal'
|
||||
import { formatWorktreeRemovalError } from '../ipc/worktree-logic'
|
||||
import { assertWorktreeCleanForRemoval, listWorktreesStrict, removeWorktree } from './worktree'
|
||||
@@ -62,17 +71,119 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
await removeTree(scratchDir)
|
||||
})
|
||||
|
||||
describe('worktree removal against the real Git binary', () => {
|
||||
it('deletes the checkout and its registration before returning', async () => {
|
||||
await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false })
|
||||
it.each(['ordinary', 'bare', 'link-before', 'link-after'])(
|
||||
'deletes the checkout and only its target administration from a %s parent',
|
||||
async (layout) => {
|
||||
if (layout === 'bare') {
|
||||
await git(['worktree', 'remove', '--force', worktreePath], repoPath)
|
||||
const bare = join(scratchDir, 'bare.git')
|
||||
await git(['clone', '--bare', '-q', repoPath, bare], scratchDir)
|
||||
repoPath = bare
|
||||
await git(['worktree', 'add', '-q', worktreePath, 'feature'], repoPath)
|
||||
}
|
||||
if (layout.startsWith('link-')) {
|
||||
if (layout === 'link-before') {
|
||||
await git(['worktree', 'remove', '--force', worktreePath], repoPath)
|
||||
}
|
||||
const metadata = join(scratchDir, 'metadata')
|
||||
await diskFs.rename(join(repoPath, '.git'), metadata)
|
||||
await diskFs.symlink(
|
||||
metadata,
|
||||
join(repoPath, '.git'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
if (layout === 'link-before') {
|
||||
await git(['worktree', 'add', '-q', worktreePath, 'feature'], repoPath)
|
||||
}
|
||||
expect((await diskFs.lstat(join(repoPath, '.git'))).isSymbolicLink()).toBe(true)
|
||||
}
|
||||
const sibling = join(scratchDir, 'unrelated')
|
||||
await git(['worktree', 'add', '-q', sibling, '-b', 'sibling'], repoPath)
|
||||
await diskFs.rename(sibling, join(scratchDir, 'offline'))
|
||||
const marker = await readFile(join(worktreePath, '.git'), 'utf8')
|
||||
const admin = marker.trim().slice(8)
|
||||
const backlink = await readFile(join(admin, 'gitdir'), 'utf8')
|
||||
console.info('owning metadata layout', { layout, marker, backlink })
|
||||
expect(
|
||||
await canSafelyRemoveOrphanedWorktreeDirectory(worktreePath, repoPath, CLIENT_REMOVAL_HOME)
|
||||
).toBe(true)
|
||||
|
||||
expect(existsSync(worktreePath)).toBe(false)
|
||||
expect(await isRegistered(worktreePath)).toBe(false)
|
||||
expect(existsSync(join(workspaceRoot, 'repo', WORKTREE_TRASH_DIR_NAME))).toBe(false)
|
||||
})
|
||||
await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false })
|
||||
|
||||
expect(existsSync(worktreePath)).toBe(false)
|
||||
expect(await isRegistered(worktreePath)).toBe(false)
|
||||
expect(existsSync(join(workspaceRoot, 'repo', WORKTREE_TRASH_DIR_NAME))).toBe(false)
|
||||
expect(existsSync(admin)).toBe(false)
|
||||
expect(await isRegistered(sibling)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32').each(['redirect', 'ls-files', 'status'])(
|
||||
'preserves the accepted checkout across bound Git checks: %s',
|
||||
async (schedule) => {
|
||||
const dispatch = vi.spyOn(nativeFs, 'rm')
|
||||
if (schedule === 'redirect') {
|
||||
const clean = join(scratchDir, 'clean')
|
||||
await git(['worktree', 'add', '-q', clean, '-b', 'clean'], repoPath)
|
||||
await git(['config', 'extensions.worktreeConfig', 'true'], repoPath)
|
||||
await git(['config', '--worktree', 'core.worktree', clean], worktreePath)
|
||||
await writeFile(join(worktreePath, 'seed.txt'), 'do not delete\n')
|
||||
vi.stubEnv('GIT_WORK_TREE', clean)
|
||||
} else {
|
||||
if (schedule === 'ls-files') {
|
||||
await writeFile(join(repoPath, '.git', 'info', 'exclude'), 'nested/\n')
|
||||
}
|
||||
const execute = runner.gitExecFileAsync
|
||||
vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
|
||||
const result = await execute(args, options)
|
||||
if (args[0] === schedule && options?.env?.GIT_WORK_TREE === worktreePath) {
|
||||
if (schedule === 'status') {
|
||||
await git(['worktree', 'lock', worktreePath], repoPath)
|
||||
} else {
|
||||
const nested = join(worktreePath, 'nested')
|
||||
await git(['worktree', 'add', '-q', nested, '-b', 'nested'], repoPath)
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
await expect(removeWorktree(repoPath, worktreePath, false)).rejects.toThrow(
|
||||
/uncommitted|locked|contains another/
|
||||
)
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
expect(await readFile(join(worktreePath, 'seed.txt'), 'utf8')).toBe(
|
||||
schedule === 'redirect' ? 'do not delete\n' : 'seed\n'
|
||||
)
|
||||
expect(await isRegistered(worktreePath)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'freezes accepted absence across the delete-slot wait',
|
||||
async () => {
|
||||
await git(['worktree', 'remove', worktreePath], repoPath)
|
||||
const paused = Promise.withResolvers<void>()
|
||||
const blockers = [0, 1].map(() => runUnderWorktreeDeleteLimit(() => paused.promise))
|
||||
const removal = removeWorktree(repoPath, worktreePath, true, {
|
||||
knownRemovedWorktree: null
|
||||
}).catch((error: unknown) => error)
|
||||
try {
|
||||
await vi.waitFor(() => expect(_worktreeDeleteLimitSnapshotForTests().waiting).toBe(1))
|
||||
await git(['worktree', 'add', '-q', '--detach', worktreePath], repoPath)
|
||||
} finally {
|
||||
paused.resolve()
|
||||
await Promise.all([...blockers, removal])
|
||||
}
|
||||
expect(String(await removal)).toContain('different checkout')
|
||||
expect(await readFile(join(worktreePath, 'seed.txt'), 'utf8')).toBe('seed\n')
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves sibling worktrees registered', async () => {
|
||||
const siblingPath = join(workspaceRoot, 'repo', 'sibling')
|
||||
|
||||
@@ -17,6 +17,9 @@ import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
|
||||
import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache'
|
||||
import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache'
|
||||
import { runUnderWorktreeDeleteLimit } from './worktree-delete-limit'
|
||||
import { removeWindowsCheckout, usesNativeWindowsRemoval } from './windows-worktree-removal'
|
||||
import { listWorktreesStrict } from './worktree-listing'
|
||||
import { isWorktreePathMissing } from '../worktree-removal-safety'
|
||||
import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue'
|
||||
|
||||
const branchCleanupQueueByRepo = new Map<string, Promise<void>>()
|
||||
@@ -50,15 +53,16 @@ async function performRemoveWorktree(
|
||||
options: RemoveWorktreeOptions = {}
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
const removedWorktree =
|
||||
options.knownRemovedWorktree ??
|
||||
(await listWorktrees(repoPath, options)).find((worktree) =>
|
||||
areWorktreePathsEqual(worktree.path, worktreePath)
|
||||
)
|
||||
options.knownRemovedWorktree !== undefined
|
||||
? options.knownRemovedWorktree
|
||||
: ((await listWorktrees(repoPath, options)).find((worktree) =>
|
||||
areWorktreePathsEqual(worktree.path, worktreePath)
|
||||
) ?? null)
|
||||
const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '')
|
||||
const branchHead = removedWorktree?.head ?? ''
|
||||
|
||||
// Enforce locks even for callers that bypass IPC/runtime preflight.
|
||||
assertWorktreeUnlockedForRemoval(removedWorktree)
|
||||
assertWorktreeUnlockedForRemoval(removedWorktree ?? undefined)
|
||||
|
||||
// Avoid default deadlines during large deletes; preserve Windows long-path support used at creation.
|
||||
const longPathArgs = windowsLongPathGitArgs(repoPath)
|
||||
@@ -74,8 +78,31 @@ async function performRemoveWorktree(
|
||||
}
|
||||
args.push(worktreePath)
|
||||
await runUnderWorktreeDeleteLimit(async () => {
|
||||
await gitExecFileAsync(args, execOptions)
|
||||
await removeCheckoutLeftByGit(worktreePath, options)
|
||||
if (usesNativeWindowsRemoval(worktreePath, options)) {
|
||||
await removeWindowsCheckout(repoPath, worktreePath, force, {
|
||||
...options,
|
||||
knownRemovedWorktree: removedWorktree
|
||||
})
|
||||
// Release only this administrative entry, and never override a newly acquired Git lock.
|
||||
if (
|
||||
(await listWorktreesStrict(repoPath, options)).some((row) =>
|
||||
areWorktreePathsEqual(row.path, worktreePath)
|
||||
)
|
||||
) {
|
||||
if (!(await isWorktreePathMissing(worktreePath))) {
|
||||
throw new Error(
|
||||
`Worktree path appeared during deletion: ${worktreePath}. Retry deletion.`
|
||||
)
|
||||
}
|
||||
await gitExecFileAsync(
|
||||
[...longPathArgs, 'worktree', 'remove', '--force', '--', worktreePath],
|
||||
execOptions
|
||||
)
|
||||
}
|
||||
} else {
|
||||
await gitExecFileAsync(args, execOptions)
|
||||
await removeCheckoutLeftByGit(worktreePath, options)
|
||||
}
|
||||
})
|
||||
|
||||
if (!branchName) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import * as fs from './asar-transparent-fs'
|
||||
import { removeHostCheckout, removeHostEmptyDirectory } from './host-tree-removal'
|
||||
|
||||
vi.mock('./asar-transparent-fs', () => ({
|
||||
readdir: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
rmdir: vi.fn(),
|
||||
lstat: vi.fn()
|
||||
}))
|
||||
vi.mock('node:timers/promises', () => ({
|
||||
// Route promise backoff through the clock installed by vi.useFakeTimers.
|
||||
// ast-grep-ignore: test-no-real-time-wait -- Schedules the installed fake clock for production backoff.
|
||||
setTimeout: (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}))
|
||||
const platform = process.platform
|
||||
const root = 'C:/checkout-partition'
|
||||
const busy = Object.assign(new Error('held child'), { code: 'EBUSY' })
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
vi.useRealTimers()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('settles public child promises before starting another pass or touching the marker', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue(['held', 'pending', '.git'])
|
||||
const pending = Promise.withResolvers<void>()
|
||||
vi.mocked(fs.rm).mockImplementation(async (target) => {
|
||||
if (String(target).endsWith('held')) {
|
||||
throw busy
|
||||
}
|
||||
await pending.promise
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const removal = removeHostCheckout(root, async () => true, controller.signal).catch(
|
||||
(error: unknown) => error
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(fs.unlink).not.toHaveBeenCalled()
|
||||
controller.abort()
|
||||
pending.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
expect(await removal).toMatchObject({ name: 'AbortError' })
|
||||
expect(fs.readdir).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([10, 100])(
|
||||
'bounds N=%i root entries across child failure and the rmdir-only tail',
|
||||
async (width) => {
|
||||
const children = Array.from({ length: width }, (_, index) => `child-${index}`)
|
||||
vi.mocked(fs.readdir).mockResolvedValue([...children, '.git'])
|
||||
let attempts = 0
|
||||
vi.mocked(fs.rm).mockImplementation(async (target) => {
|
||||
if (String(target).endsWith('child-0') && ++attempts < 3) {
|
||||
throw busy
|
||||
}
|
||||
})
|
||||
let rootAttempts = 0
|
||||
vi.mocked(fs.rmdir).mockImplementation(async () => {
|
||||
throw Object.assign(new Error('new marker sentinel'), {
|
||||
code: ++rootAttempts === 1 ? 'EBUSY' : 'ENOTEMPTY'
|
||||
})
|
||||
})
|
||||
const authorize = vi.fn(async () => true)
|
||||
const removal = removeHostCheckout(root, authorize).catch((error: unknown) => error)
|
||||
await vi.runAllTimersAsync()
|
||||
expect(String(await removal)).toContain('folder was left on disk')
|
||||
expect(fs.rm).toHaveBeenCalledTimes(3 * width)
|
||||
expect(fs.rmdir).toHaveBeenCalledTimes(3)
|
||||
expect(fs.unlink).toHaveBeenCalledOnce()
|
||||
expect(fs.readdir).toHaveBeenCalledTimes(3)
|
||||
expect(authorize).toHaveBeenCalledTimes(6)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not mistake Windows rmdir ENOENT on an existing file for absence', async () => {
|
||||
const missing = Object.assign(new Error('not a directory'), { code: 'ENOENT' })
|
||||
vi.mocked(fs.rmdir).mockRejectedValue(missing)
|
||||
await expect(removeHostEmptyDirectory(root)).rejects.toBe(missing)
|
||||
const denied = Object.assign(new Error('unreadable replacement'), { code: 'EACCES' })
|
||||
vi.mocked(fs.lstat).mockRejectedValue(denied)
|
||||
await expect(removeHostEmptyDirectory(root)).rejects.toBe(denied)
|
||||
vi.mocked(fs.lstat).mockRejectedValue(missing)
|
||||
await expect(removeHostEmptyDirectory(root)).resolves.toBeUndefined()
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
// Host deletion needs bounded Windows lock retries and unpatched fs for Electron’s .asar handling.
|
||||
|
||||
import { win32 } from 'node:path'
|
||||
import { rm } from './asar-transparent-fs'
|
||||
import { getErrorCode } from './git/worktree-operation-options'
|
||||
import { unregisteredFolderRefusal } from '../shared/worktree/removal'
|
||||
import { join, win32 } from 'node:path'
|
||||
import { lstat, readdir, rm, rmdir, unlink } from './asar-transparent-fs'
|
||||
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
|
||||
import { isWslUncPath } from '../shared/wsl-paths'
|
||||
import { stripWin32LongPathPrefix } from '../shared/windows-path-access'
|
||||
@@ -34,6 +36,81 @@ export async function removeHostTree(
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep checkout proof out of recursive walks; one shared budget covers children and the final rmdir. */
|
||||
export async function removeHostCheckout(
|
||||
targetPath: string,
|
||||
authorize: (checkClean: boolean) => Promise<boolean>,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const root = toHostRemovalPath(targetPath)
|
||||
let markerGone = false
|
||||
try {
|
||||
await removeTree(root, {
|
||||
signal,
|
||||
rm: async () => {
|
||||
if (markerGone) {
|
||||
await authorize(false)
|
||||
} else {
|
||||
markerGone = !(await authorize(true))
|
||||
if (!markerGone) {
|
||||
const names = (await readdir(root)).filter((name) => name.toLowerCase() !== '.git')
|
||||
const results = await Promise.allSettled(
|
||||
names.map((name) => rm(join(root, name), { recursive: true, force: true }))
|
||||
)
|
||||
const failures = results.filter((result) => result.status === 'rejected')
|
||||
const failure =
|
||||
failures.find(
|
||||
({ reason }) => !['EBUSY', 'EPERM', 'ENOTEMPTY'].includes(reason?.code)
|
||||
) ?? failures[0]
|
||||
if (failure) {
|
||||
throw failure.reason
|
||||
}
|
||||
// Child promises settled; their internal Node sibling walks may still be running.
|
||||
markerGone = !(await authorize(false))
|
||||
if (!markerGone) {
|
||||
try {
|
||||
await unlink(join(root, '.git'))
|
||||
} catch (error) {
|
||||
if (getErrorCode(error) !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
markerGone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
await removeHostEmptyDirectory(root)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (markerGone && getErrorCode(error) === 'ENOTEMPTY') {
|
||||
throw unregisteredFolderRefusal(targetPath)
|
||||
}
|
||||
throw describeRemovalFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeHostEmptyDirectory(targetPath: string): Promise<void> {
|
||||
const root = toHostRemovalPath(targetPath)
|
||||
try {
|
||||
await rmdir(root)
|
||||
} catch (error) {
|
||||
if (getErrorCode(error) !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
// Windows rmdir can report ENOENT for an existing regular-file replacement.
|
||||
try {
|
||||
await lstat(root)
|
||||
} catch (statError) {
|
||||
if (getErrorCode(statError) === 'ENOENT') {
|
||||
return
|
||||
}
|
||||
throw statError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Failed-delete rows show this message; name the blocking file instead of Node's error text.
|
||||
function describeRemovalFailure(error: unknown): unknown {
|
||||
const code = error instanceof Error && 'code' in error ? error.code : undefined
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import * as diskFs from 'node:fs/promises'
|
||||
import * as gitRunner from '../git/runner'
|
||||
import { removeWorktree } from '../git/worktree'
|
||||
import { removeWorktree as removeNativeWorktree } from '../git/worktree-removal'
|
||||
import { readTranslatedWorktreeGraph } from '../git/worktree-list-reader'
|
||||
import { toHostFilesystemPath } from '../host-tree-removal'
|
||||
import { removeTree } from '../../shared/windows-transient-lock-removal'
|
||||
import * as scopedRootsInvalidation from './registered-worktree-roots-scoped-invalidation'
|
||||
import * as authorizedRootsCache from './registered-worktree-roots-cache'
|
||||
import * as localWorktreeFilesystem from '../local-worktree-filesystem'
|
||||
@@ -45,9 +54,10 @@ vi.mock('electron', async () =>
|
||||
vi.mock('../git/worktree', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
|
||||
)
|
||||
vi.mock('../git/runner', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
|
||||
)
|
||||
vi.mock('../git/runner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof gitRunner>()),
|
||||
...(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
|
||||
}))
|
||||
vi.mock('../git/repo', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
|
||||
)
|
||||
@@ -116,6 +126,7 @@ vi.mock('./pty/provider/state-cleanup', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).ptyStateCleanupModuleMock()
|
||||
)
|
||||
|
||||
const actualGitRunner = await vi.importActual<typeof gitRunner>('../git/runner')
|
||||
const anyCatalogVersion = { epoch: expect.any(String), sequence: expect.any(Number) }
|
||||
|
||||
describe('registerWorktreeHandlers', () => {
|
||||
@@ -416,166 +427,63 @@ describe('registerWorktreeHandlers', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('recovers forced Windows long-path worktree removal through local deletion and prune', async () => {
|
||||
setPlatform('win32')
|
||||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-long-path-'))
|
||||
const repoPath = join(parentDir, 'repo')
|
||||
const worktreePath = join(parentDir, 'feature-wt')
|
||||
await mkdir(worktreePath, { recursive: true })
|
||||
await writeFile(join(worktreePath, 'scratch.txt'), 'delete me')
|
||||
const registeredWorktrees = mockKnownFeatureWorktree(worktreePath, repoPath)
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce(registeredWorktrees)
|
||||
.mockResolvedValueOnce(registeredWorktrees)
|
||||
.mockResolvedValue([])
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||||
const longPathError = Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||||
})
|
||||
removeWorktreeMock.mockRejectedValue(longPathError)
|
||||
const worktreeId = `repo-1::${worktreePath}`
|
||||
it.skipIf(ORIGINAL_PLATFORM !== 'win32')(
|
||||
'force-deletes a real registered Windows long-path checkout through desktop Delete',
|
||||
async () => {
|
||||
const parentDir = await diskFs.realpath(await mkdtemp(join(tmpdir(), 'orca-ipc-long-path-')))
|
||||
const repoPath = join(parentDir, 'repo')
|
||||
const worktreePath = join(parentDir, 'feature-wt')
|
||||
const worktreeId = `repo-1::${worktreePath}`
|
||||
const git = async (args: string[]) =>
|
||||
(await promisify(execFile)('git', args, { cwd: repoPath })).stdout
|
||||
try {
|
||||
await mkdir(repoPath)
|
||||
await git(['init', '-q', '-b', 'main'])
|
||||
await git(['config', 'user.email', 'removal@example.invalid'])
|
||||
await git(['config', 'user.name', 'Worktree Removal'])
|
||||
await git(['config', 'maintenance.auto', 'false'])
|
||||
await git(['config', 'gc.auto', '0'])
|
||||
await git(['commit', '--allow-empty', '-qm', 'seed'])
|
||||
await git(['worktree', 'add', '-q', '-b', 'feature', worktreePath])
|
||||
const deep = join(worktreePath, ...Array.from({ length: 6 }, () => 'deep-'.repeat(10)))
|
||||
await mkdir(toHostFilesystemPath(deep), { recursive: true })
|
||||
await writeFile(toHostFilesystemPath(join(deep, 'scratch.txt')), 'delete me')
|
||||
expect(join(deep, 'scratch.txt').length).toBeGreaterThan(260)
|
||||
expect((await lstat(join(worktreePath, '.git'))).isFile()).toBe(true)
|
||||
const admin = (await diskFs.readFile(join(worktreePath, '.git'), 'utf8')).trim().slice(8)
|
||||
vi.mocked(gitRunner.gitExecFileAsync).mockImplementation(actualGitRunner.gitExecFileAsync)
|
||||
listWorktreesMock.mockImplementation((repo) => readTranslatedWorktreeGraph(repo))
|
||||
vi.mocked(removeWorktree).mockImplementation(removeNativeWorktree)
|
||||
store.getRepo.mockReturnValue({ ...harnessRepo, path: repoPath, worktreeBaseRef: null })
|
||||
store.getRepos.mockReturnValue([{ ...harnessRepo, path: repoPath }])
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||||
store.removeWorktreeMeta.mockImplementation(() =>
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
)
|
||||
expect(await readTranslatedWorktreeGraph(repoPath)).toHaveLength(2)
|
||||
const head = (await git(['rev-parse', 'feature'])).trim()
|
||||
|
||||
try {
|
||||
const result = await handlers['worktrees:remove'](null, {
|
||||
worktreeId,
|
||||
force: true
|
||||
})
|
||||
const result = await handlers['worktrees:remove'](null, { worktreeId, force: true })
|
||||
|
||||
expect(result).toEqual({
|
||||
preservedBranch: { branchName: 'feature', head: 'feature' },
|
||||
catalogVersion: anyCatalogVersion
|
||||
})
|
||||
if (ORIGINAL_PLATFORM === 'win32') {
|
||||
expect(result).toEqual({ catalogVersion: anyCatalogVersion })
|
||||
await expect(lstat(worktreePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(lstat(admin)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await readTranslatedWorktreeGraph(repoPath)).toHaveLength(1)
|
||||
expect(await git(['branch', '--list', 'feature'])).toBe('')
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: worktreeId, hostId: 'local' })
|
||||
)
|
||||
expect(store.getWorktreeMeta(worktreeId)).toBeUndefined()
|
||||
await expect(
|
||||
handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||||
worktreeId,
|
||||
branchName: 'feature',
|
||||
expectedHead: head
|
||||
})
|
||||
).rejects.toThrow('No preserved branch cleanup is pending')
|
||||
} finally {
|
||||
await removeTree(toHostFilesystemPath(parentDir))
|
||||
}
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||||
cwd: '/workspace/repo'
|
||||
})
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: worktreeId, hostId: 'local' })
|
||||
)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
} finally {
|
||||
await rm(parentDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not create a preserved-branch target when long-path recovery preserves branch by policy', async () => {
|
||||
setPlatform('win32')
|
||||
const registeredWorktrees = mockKnownFeatureWorktree()
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce(registeredWorktrees)
|
||||
.mockResolvedValueOnce(registeredWorktrees)
|
||||
.mockResolvedValue([])
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true }))
|
||||
removeWorktreeMock.mockRejectedValue(
|
||||
Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||||
})
|
||||
)
|
||||
|
||||
const result = await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
force: true
|
||||
})
|
||||
|
||||
expect(result).toEqual({ catalogVersion: anyCatalogVersion })
|
||||
await expect(
|
||||
handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
branchName: 'feature',
|
||||
expectedHead: 'feature'
|
||||
})
|
||||
).rejects.toThrow('No preserved branch cleanup is pending')
|
||||
})
|
||||
|
||||
it('does not recover Windows long-path worktree removal without force', async () => {
|
||||
setPlatform('win32')
|
||||
mockKnownFeatureWorktree()
|
||||
const longPathError = Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||||
})
|
||||
removeWorktreeMock.mockRejectedValue(longPathError)
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||||
})
|
||||
).rejects.toThrow('Failed to delete worktree at /workspace/feature-wt.')
|
||||
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses Windows recovery while Git still reports the row and keeps metadata', async () => {
|
||||
setPlatform('win32')
|
||||
mockKnownFeatureWorktree()
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||||
const removePathSpy = vi
|
||||
.spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath')
|
||||
.mockResolvedValue(undefined)
|
||||
removeWorktreeMock.mockRejectedValue(
|
||||
Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
force: true
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Failed to force delete worktree at /workspace/feature-wt. error: failed to delete deep/file.txt: Filename too long'
|
||||
)
|
||||
|
||||
expect(removePathSpy).not.toHaveBeenCalled()
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||||
['worktree', 'prune'],
|
||||
expect.anything()
|
||||
)
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
removePathSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'retries missing registration cleanup (prunable marker: %s)',
|
||||
async (prunableMarker) => {
|
||||
setPlatform('win32')
|
||||
const missingWorktreePath = prunableMarker
|
||||
? 'C:\\workspace\\already-removed\\.git'
|
||||
: 'C:\\workspace\\already-removed'
|
||||
const worktreeId = `repo-1::${missingWorktreePath}`
|
||||
const registeredWorktrees = mockKnownFeatureWorktree(missingWorktreePath).map((row) =>
|
||||
prunableMarker && row.path === missingWorktreePath
|
||||
? { ...row, branch: 'refs/heads/feature', prunable: true }
|
||||
: row
|
||||
)
|
||||
listWorktreesMock.mockResolvedValueOnce(registeredWorktrees).mockResolvedValue([])
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||||
|
||||
const result = await handlers['worktrees:remove'](null, {
|
||||
worktreeId,
|
||||
force: true
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
preservedBranch: { branchName: 'feature', head: 'feature' },
|
||||
catalogVersion: anyCatalogVersion
|
||||
})
|
||||
expect(runHookMock).not.toHaveBeenCalled()
|
||||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||||
cwd: '/workspace/repo'
|
||||
})
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: worktreeId, hostId: 'local' })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -141,10 +141,10 @@ export function buildDetectedGitWorktrees(
|
||||
const settings = store.getSettings()
|
||||
const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo)
|
||||
// Prunable registrations lack working directories; omit from display while keeping cleanup discovery intact (#8389).
|
||||
// Keep failed removals retryable; other prunable registrations stay hidden (#8389).
|
||||
const liveWorktrees = dedupeWorktreesByPath(
|
||||
preserveFolderUpgradeWorktreePath(repo, gitWorktrees).filter(
|
||||
(gitWorktree) => !gitWorktree.prunable
|
||||
(gitWorktree) => !gitWorktree.prunable || !!gitWorktree.removalError
|
||||
)
|
||||
)
|
||||
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
|
||||
|
||||
@@ -9,6 +9,11 @@ import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-
|
||||
import { listWorktreesStrict as listGitWorktreesStrict } from '../../../git/worktree'
|
||||
import { SshHostNotServedError } from '../../../providers/ssh-host-not-served-error'
|
||||
import { resolveWorktreeRemovalMetadata } from '../../../worktree-removal-repo-owner'
|
||||
import {
|
||||
authorizeUnregisteredWindowsRemoval,
|
||||
nativeWindowsMarkerMissing,
|
||||
usesNativeWindowsRemoval
|
||||
} from '../../../git/windows-worktree-removal'
|
||||
import { isPrunableGitFileWorktree } from '../../../worktree-prunable-git-file'
|
||||
import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety'
|
||||
import { assertNestedWorktreeRemovalApproval } from '../../../nested-worktree-removal-plan'
|
||||
@@ -69,11 +74,19 @@ export async function executeWorktreeRemoval(
|
||||
args.expectedCheckout
|
||||
])
|
||||
}
|
||||
await authorizeUnregisteredWindowsRemoval(
|
||||
!!registeredWorktree,
|
||||
repo.path,
|
||||
worktreePath,
|
||||
args.force ?? false,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
if (
|
||||
await retryFailedRemovalUnlessRegistered(
|
||||
args.worktreeId,
|
||||
worktreePath,
|
||||
registeredWorktrees,
|
||||
localWorktreeGitOptions,
|
||||
() => retryFailedLocalWorktreeRemoval(context, args, removalHostId)
|
||||
)
|
||||
) {
|
||||
@@ -105,12 +118,13 @@ export async function executeWorktreeRemoval(
|
||||
|
||||
// Stale registrations have no checkout to archive; missing cwd must not block metadata cleanup.
|
||||
if (
|
||||
(await isPrunableGitFileWorktree(registeredWorktree, localWorktreeGitOptions)) ||
|
||||
(args.force === true &&
|
||||
process.platform === 'win32' &&
|
||||
(isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) &&
|
||||
removedMeta &&
|
||||
(await isAlreadyRemovedWorktreePath(canonicalWorktreePath, localWorktreeGitOptions)))
|
||||
!usesNativeWindowsRemoval(canonicalWorktreePath, localWorktreeGitOptions) &&
|
||||
((await isPrunableGitFileWorktree(registeredWorktree, localWorktreeGitOptions)) ||
|
||||
(args.force === true &&
|
||||
process.platform === 'win32' &&
|
||||
(isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) &&
|
||||
removedMeta &&
|
||||
(await isAlreadyRemovedWorktreePath(canonicalWorktreePath, localWorktreeGitOptions))))
|
||||
) {
|
||||
const removalResult = await removeStaleLocalWorktreeRegistration({
|
||||
canonicalWorktreePath,
|
||||
@@ -151,7 +165,11 @@ export async function executeWorktreeRemoval(
|
||||
|
||||
// Archive failure must refuse before PTY teardown or checkout deletion (#19334).
|
||||
let archiveHookOverride: ArchiveHookOverride | undefined
|
||||
if (archiveScript && !args.skipArchive) {
|
||||
if (
|
||||
archiveScript &&
|
||||
!args.skipArchive &&
|
||||
!(await nativeWindowsMarkerMissing(canonicalWorktreePath, localWorktreeGitOptions))
|
||||
) {
|
||||
await withWorktreeRemoveStageSpan('archive_hook', 'local', async () => {
|
||||
const result = await runHook(
|
||||
'archive',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { GitWorktreeInfo } from '../../../../shared/worktree/types'
|
||||
import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options'
|
||||
import {
|
||||
authorizeWindowsCheckout,
|
||||
usesNativeWindowsRemoval
|
||||
} from '../../../git/windows-worktree-removal'
|
||||
import { assertWorktreeCleanForRemoval } from '../../../git/worktree'
|
||||
import { formatWorktreeRemovalError, isOrphanCompatiblePreflightError } from '../../worktree-logic'
|
||||
|
||||
export async function assertRegisteredLocalWorktreePreflight(
|
||||
repoPath: string,
|
||||
canonicalWorktreePath: string,
|
||||
worktree: GitWorktreeInfo,
|
||||
force: boolean,
|
||||
localOptions: LocalProjectWorktreeGitOptions,
|
||||
hasLocalOptions: boolean,
|
||||
ignoredLinkedPaths: readonly string[],
|
||||
markerMissing: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (usesNativeWindowsRemoval(canonicalWorktreePath, localOptions)) {
|
||||
await authorizeWindowsCheckout(
|
||||
repoPath,
|
||||
canonicalWorktreePath,
|
||||
force,
|
||||
{
|
||||
...localOptions,
|
||||
knownRemovedWorktree: worktree,
|
||||
ignoredUntrackedPaths: ignoredLinkedPaths
|
||||
},
|
||||
true
|
||||
)
|
||||
} else if (!markerMissing) {
|
||||
await (hasLocalOptions
|
||||
? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, {
|
||||
...localOptions,
|
||||
...(ignoredLinkedPaths.length > 0 ? { ignoredUntrackedPaths: ignoredLinkedPaths } : {})
|
||||
})
|
||||
: ignoredLinkedPaths.length > 0
|
||||
? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, {
|
||||
ignoredUntrackedPaths: ignoredLinkedPaths
|
||||
})
|
||||
: assertWorktreeCleanForRemoval(canonicalWorktreePath, force))
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isOrphanCompatiblePreflightError(error)) {
|
||||
throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force))
|
||||
}
|
||||
// Git may discover orphaning after preflight; recursive fallback still requires strict PTY teardown.
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { assertRegisteredLocalWorktreePreflight } from './registered-local-worktree-preflight'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import {
|
||||
nativeWindowsMarkerMissing,
|
||||
usesNativeWindowsRemoval
|
||||
} from '../../../git/windows-worktree-removal'
|
||||
import { pruneGitWorktrees } from '../../../git/worktree-prune-guard'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types'
|
||||
@@ -6,7 +11,6 @@ import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree
|
||||
import { assertWorktreeUnlockedForRemoval } from '../../../../shared/worktree/removal'
|
||||
import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options'
|
||||
import {
|
||||
assertWorktreeCleanForRemoval,
|
||||
listWorktreesStrict as listGitWorktreesStrict,
|
||||
removeWorktree
|
||||
} from '../../../git/worktree'
|
||||
@@ -24,11 +28,7 @@ import {
|
||||
} from '../../worktree-symlinks'
|
||||
import { invalidateAuthorizedRootsCacheForRepo } from '../../registered-worktree-roots-scoped-invalidation'
|
||||
import { runWorktreeChangeInvalidators } from '../../worktree-change-invalidators'
|
||||
import {
|
||||
formatWorktreeRemovalError,
|
||||
isOrphanCompatiblePreflightError,
|
||||
isOrphanedWorktreeError
|
||||
} from '../../worktree-logic'
|
||||
import { formatWorktreeRemovalError, isOrphanedWorktreeError } from '../../worktree-logic'
|
||||
import type { RemoveWorktreeArgs } from '../ipc-context-schemas'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
import {
|
||||
@@ -88,23 +88,20 @@ export async function removeRegisteredLocalWorktree(
|
||||
const ignoredLinkedPaths = args.force
|
||||
? []
|
||||
: await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths)
|
||||
try {
|
||||
await (hasLocalWorktreeGitOptions
|
||||
? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, {
|
||||
...localWorktreeGitOptions,
|
||||
...(ignoredLinkedPaths.length > 0 ? { ignoredUntrackedPaths: ignoredLinkedPaths } : {})
|
||||
})
|
||||
: ignoredLinkedPaths.length > 0
|
||||
? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, {
|
||||
ignoredUntrackedPaths: ignoredLinkedPaths
|
||||
})
|
||||
: assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false))
|
||||
} catch (error) {
|
||||
if (!isOrphanCompatiblePreflightError(error)) {
|
||||
throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false))
|
||||
}
|
||||
// Git may discover orphaning after preflight; recursive fallback still requires strict PTY teardown.
|
||||
}
|
||||
const markerMissing = await nativeWindowsMarkerMissing(
|
||||
canonicalWorktreePath,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
await assertRegisteredLocalWorktreePreflight(
|
||||
repo.path,
|
||||
canonicalWorktreePath,
|
||||
refreshedRegisteredWorktree,
|
||||
args.force ?? false,
|
||||
localWorktreeGitOptions,
|
||||
hasLocalWorktreeGitOptions,
|
||||
ignoredLinkedPaths,
|
||||
markerMissing
|
||||
)
|
||||
|
||||
const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () =>
|
||||
runtime.acquireFileWatcherRemoval(canonicalWorktreePath)
|
||||
@@ -118,7 +115,7 @@ export async function removeRegisteredLocalWorktree(
|
||||
})
|
||||
})
|
||||
|
||||
if (linkedPaths.length > 0) {
|
||||
if (!markerMissing && linkedPaths.length > 0) {
|
||||
await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths)
|
||||
}
|
||||
accepted = true
|
||||
@@ -233,6 +230,9 @@ async function finishLocalWorktreeRemoval({
|
||||
refreshedRegisteredWorktree.head
|
||||
)
|
||||
} catch (error) {
|
||||
if (usesNativeWindowsRemoval(canonicalWorktreePath, localWorktreeGitOptions)) {
|
||||
throw error
|
||||
}
|
||||
// Git for Windows may deregister before recursive filesystem deletion fails.
|
||||
const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({
|
||||
error,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { removeWorktree } from '../../../git/worktree-removal'
|
||||
import { usesNativeWindowsRemoval } from '../../../git/windows-worktree-removal'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types'
|
||||
@@ -60,22 +62,29 @@ export async function removeUnregisteredWorktree(
|
||||
toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions),
|
||||
toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions),
|
||||
removalHome,
|
||||
access.statPath,
|
||||
access.readPath
|
||||
access
|
||||
))
|
||||
}
|
||||
if (canCleanOrphanedDirectory) {
|
||||
const native = usesNativeWindowsRemoval(worktreePath, localWorktreeGitOptions)
|
||||
if (canCleanOrphanedDirectory || (native && removedMeta)) {
|
||||
assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees)
|
||||
if (!args.force) {
|
||||
if (canCleanOrphanedDirectory && !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)
|
||||
await (usesNativeWindowsRemoval(worktreePath, localWorktreeGitOptions)
|
||||
? removeWorktree(repo.path, worktreePath, args.force ?? false, {
|
||||
...localWorktreeGitOptions,
|
||||
deleteBranch: false,
|
||||
knownRemovedWorktree: null
|
||||
})
|
||||
: removeLocalWorktreePath(worktreePath, localWorktreeGitOptions))
|
||||
removalCompleted = true
|
||||
} finally {
|
||||
await removalGate.finish(removalCompleted)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { interruptedLocalWorktreeRemovalJob } from '../../../runtime/runtime-int
|
||||
import { invalidateAuthorizedRootsCacheForRepo } from '../../registered-worktree-roots-scoped-invalidation'
|
||||
import type { RemoveWorktreeArgs } from '../ipc-context-schemas'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
import type { WorktreeRemovalRecord } from '../../../worktree-removal-records'
|
||||
import {
|
||||
preserveBranchHeadFallback,
|
||||
rememberPreservedBranchCleanupTarget
|
||||
@@ -21,7 +22,7 @@ export function retryFailedLocalWorktreeRemoval(
|
||||
removalHostId: ExecutionHostId
|
||||
): Promise<RemoveWorktreeResult> | undefined {
|
||||
const { store, runtime, options } = context
|
||||
return retryFailedWorktreeRemoval(args.worktreeId, removalHostId, (record) =>
|
||||
const jobFor = (record: WorktreeRemovalRecord) =>
|
||||
interruptedLocalWorktreeRemovalJob(record, {
|
||||
store,
|
||||
acquireWatcherRemoval: (path) => runtime.acquireFileWatcherRemoval(path),
|
||||
@@ -55,5 +56,5 @@ export function retryFailedLocalWorktreeRemoval(
|
||||
options?.onWorktreeLifecycle?.({ kind: 'removed', worktreeId, path: worktreePath }),
|
||||
publish: (repoId) => runtime.publishWorktreeRemovalChange(repoId)
|
||||
})
|
||||
)
|
||||
return retryFailedWorktreeRemoval(args.worktreeId, removalHostId, jobFor, args.force ?? false)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ export async function cleanupLocalOrphanedWorktreeDirectory(
|
||||
runtimePath,
|
||||
toLocalWorktreeRuntimePath(repoPath, options),
|
||||
CLIENT_REMOVAL_HOME,
|
||||
access.statPath,
|
||||
access.readPath
|
||||
access
|
||||
)
|
||||
) {
|
||||
await closeWatchers(path)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { runProcessMock, lstatMock, readFileMock, rmMock } = vi.hoisted(() => ({
|
||||
runProcessMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
rmMock: vi.fn()
|
||||
}))
|
||||
const { runProcessMock, lstatMock, statMock, realpathMock, readFileMock, rmMock } = vi.hoisted(
|
||||
() => ({
|
||||
runProcessMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
statMock: vi.fn(),
|
||||
realpathMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
rmMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
// Process-host tests own encoding, timeouts, and console hiding; assert guest commands here.
|
||||
vi.mock('@orca/process-host', () => ({
|
||||
@@ -14,6 +18,8 @@ vi.mock('@orca/process-host', () => ({
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
lstat: lstatMock,
|
||||
stat: statMock,
|
||||
realpath: realpathMock,
|
||||
readFile: readFileMock,
|
||||
rm: rmMock
|
||||
}))
|
||||
@@ -54,6 +60,8 @@ describe('local worktree filesystem runtime access', () => {
|
||||
beforeEach(() => {
|
||||
runProcessMock.mockReset()
|
||||
lstatMock.mockReset()
|
||||
statMock.mockReset()
|
||||
realpathMock.mockReset()
|
||||
readFileMock.mockReset()
|
||||
rmMock.mockReset()
|
||||
completeExecFile()
|
||||
@@ -65,15 +73,21 @@ describe('local worktree filesystem runtime access', () => {
|
||||
})
|
||||
|
||||
it('uses host filesystem operations when no WSL distro is selected', async () => {
|
||||
lstatMock.mockResolvedValue({ type: 'file' })
|
||||
lstatMock.mockResolvedValue({ type: 'symlink' })
|
||||
statMock.mockResolvedValue({ type: 'directory' })
|
||||
realpathMock.mockResolvedValue('C:\\metadata')
|
||||
readFileMock.mockResolvedValue('gitdir: ../.git/worktrees/feature')
|
||||
|
||||
const access = getLocalWorktreePathAccess()
|
||||
await access.statPath('C:\\repo\\.git')
|
||||
await expect(access.statPath('C:\\repo\\.git')).resolves.toEqual({ type: 'symlink' })
|
||||
await expect(access.layoutStatPath('C:\\repo\\.git')).resolves.toEqual({ type: 'directory' })
|
||||
await expect(access.realPath('C:\\repo\\.git')).resolves.toBe('C:\\metadata')
|
||||
await access.readPath('C:\\repo\\.git')
|
||||
await removeLocalWorktreePath('C:\\repo\\feature')
|
||||
|
||||
expect(lstatMock).toHaveBeenCalledWith('C:\\repo\\.git')
|
||||
expect(statMock).toHaveBeenCalledWith('C:\\repo\\.git')
|
||||
expect(realpathMock).toHaveBeenCalledWith('C:\\repo\\.git')
|
||||
expect(readFileMock).toHaveBeenCalledWith('C:\\repo\\.git', 'utf8')
|
||||
expect(rmMock).toHaveBeenCalledWith(
|
||||
toHostRemovalPath('C:\\repo\\feature'),
|
||||
@@ -170,19 +184,41 @@ describe('local worktree filesystem runtime access', () => {
|
||||
|
||||
it('uses the selected WSL distro for stat, read, and removal on Windows', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
completeExecFile('regular file')
|
||||
completeExecFile('symbolic link')
|
||||
const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' })
|
||||
await expect(access.statPath('/home/me/repo/.git')).resolves.toEqual({ type: 'file' })
|
||||
|
||||
completeExecFile('gitdir: /home/me/repo/.git/worktrees/feature\n')
|
||||
await expect(access.readPath('/home/me/repo/.git')).resolves.toBe(
|
||||
'gitdir: /home/me/repo/.git/worktrees/feature\n'
|
||||
await expect(access.statPath('/home/me/repo/.git')).resolves.toEqual({ type: 'symlink' })
|
||||
completeExecFile('directory')
|
||||
await expect(access.layoutStatPath('/home/me/repo/.git')).resolves.toEqual({
|
||||
type: 'directory'
|
||||
})
|
||||
expect(runProcessMock.mock.calls.slice(0, 2).map(([request]) => request.args.at(-1))).toEqual(
|
||||
[
|
||||
"LC_ALL=C stat -c %F -- '/home/me/repo/.git'",
|
||||
"LC_ALL=C stat -L -c %F -- '/home/me/repo/.git'"
|
||||
]
|
||||
)
|
||||
|
||||
completeExecFile('/home/me/metadata with spaces \n')
|
||||
await expect(access.realPath('/home/me/repo/.git')).resolves.toBe(
|
||||
'/home/me/metadata with spaces '
|
||||
)
|
||||
expect(runProcessMock.mock.calls[2]?.[0].args.at(-1)).toBe(
|
||||
"LC_ALL=C realpath -e -- '/home/me/repo/.git'"
|
||||
)
|
||||
completeExecFile(' gitdir: /home/me/repo with spaces/.git/worktrees/feature \n')
|
||||
await expect(access.readPath("/home/me/repo's file")).resolves.toBe(
|
||||
' gitdir: /home/me/repo with spaces/.git/worktrees/feature \n'
|
||||
)
|
||||
expect(runProcessMock.mock.calls[3]?.[0]).toMatchObject({
|
||||
program: 'wsl.exe',
|
||||
args: ['-d', 'Ubuntu', '--exec', 'sh', '-c', "LC_ALL=C cat -- '/home/me/repo'\\''s file'"],
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
completeExecFile()
|
||||
await removeLocalWorktreePath('C:\\Users\\me\\repo feature', { wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(3)
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(5)
|
||||
expect(runProcessMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
@@ -192,9 +228,11 @@ describe('local worktree filesystem runtime access', () => {
|
||||
cwd: expect.any(String)
|
||||
})
|
||||
)
|
||||
const removeArgs = runProcessMock.mock.calls[2]?.[0].args as string[]
|
||||
expect(removeArgs.at(-1)).toContain('rm -rf --')
|
||||
expect(removeArgs.at(-1)).toContain(String.raw`rm -rf -- '/mnt/c/Users/me/repo feature'`)
|
||||
const removeArgs: unknown = runProcessMock.mock.calls[4]?.[0].args
|
||||
if (!Array.isArray(removeArgs) || !removeArgs.every((arg) => typeof arg === 'string')) {
|
||||
throw new Error('Expected WSL removal command arguments')
|
||||
}
|
||||
expect(removeArgs.at(-1)).toBe(String.raw`rm -rf -- '/mnt/c/Users/me/repo feature'`)
|
||||
expect(rmMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -210,6 +248,12 @@ describe('local worktree filesystem runtime access', () => {
|
||||
expect(args).not.toContain('-lc')
|
||||
expect(args).not.toContain('-ilc')
|
||||
expect(args.at(-1)).not.toContain('getent passwd')
|
||||
await expect(
|
||||
getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }).layoutStatPath('/home/me/repo/.git')
|
||||
).resolves.toEqual({ type: 'other' })
|
||||
await expect(
|
||||
getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }).realPath('/home/me/repo/.git')
|
||||
).rejects.toThrow('Invalid WSL canonical path')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -224,9 +268,9 @@ describe('local worktree filesystem runtime access', () => {
|
||||
failExecFile(1, stderr)
|
||||
const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' })
|
||||
|
||||
await expect(access.statPath('/mnt/c/repo/missing/.git')).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
for (const inspect of [access.statPath, access.layoutStatPath, access.realPath]) {
|
||||
await expect(inspect('/mnt/c/repo/missing/.git')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
}
|
||||
await expect(isWorktreePathMissing('/mnt/c/repo/missing', access.statPath)).resolves.toBe(
|
||||
true
|
||||
)
|
||||
@@ -243,7 +287,9 @@ describe('local worktree filesystem runtime access', () => {
|
||||
failExecFile(1, stderr)
|
||||
const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' })
|
||||
|
||||
await expect(access.statPath('/mnt/c/repo/locked')).rejects.toThrow('Permission denied')
|
||||
for (const inspect of [access.statPath, access.layoutStatPath, access.realPath]) {
|
||||
await expect(inspect('/mnt/c/repo/locked')).rejects.toThrow('Permission denied')
|
||||
}
|
||||
await expect(isWorktreePathMissing('/mnt/c/repo/locked', access.statPath)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
@@ -251,6 +297,20 @@ describe('local worktree filesystem runtime access', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('normalizes only definitive cat absence, preserving path-bearing read failures', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' })
|
||||
const path = '/mnt/c/ENOENT/repo/.git/commondir'
|
||||
// Synthetic cat-shaped diagnostics exercise the same transport as the recorded stat errors.
|
||||
for (const reason of ['No such file or directory', 'Not a directory']) {
|
||||
failExecFile(1, `cat: ${path}: ${reason}\n`)
|
||||
await expect(access.readPath(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
}
|
||||
failExecFile(1, `cat: ${path}: Permission denied\n`)
|
||||
await expect(access.readPath(path)).rejects.not.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
})
|
||||
|
||||
it('pins the stat probe to the C locale so its error text stays English', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
completeExecFile('directory')
|
||||
@@ -267,9 +327,14 @@ describe('local worktree filesystem runtime access', () => {
|
||||
failExecFile(1, "stat: <translated verb> '/mnt/c/repo/missing': <translated errno>\n")
|
||||
const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' })
|
||||
|
||||
await expect(access.statPath('/mnt/c/repo/missing')).rejects.not.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
for (const inspect of [
|
||||
access.statPath,
|
||||
access.layoutStatPath,
|
||||
access.realPath,
|
||||
access.readPath
|
||||
]) {
|
||||
await expect(inspect('/mnt/c/repo/missing')).rejects.not.toMatchObject({ code: 'ENOENT' })
|
||||
}
|
||||
await expect(isWorktreePathMissing('/mnt/c/repo/missing', access.statPath)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { posix } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { lstat, readFile } from 'node:fs/promises'
|
||||
import { lstat, stat, readFile, realpath } from 'node:fs/promises'
|
||||
import { buildWslExecArgs } from '../shared/wsl-login-shell-command'
|
||||
import { quotePosixShell } from '../shared/shell-argument-quoting'
|
||||
import { removeHostTree } from './host-tree-removal'
|
||||
import { toLinuxPath } from './wsl'
|
||||
import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory'
|
||||
import { describeWslProcessFailure } from './wsl/wsl-process-failure'
|
||||
import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof'
|
||||
import type { WorktreeProofAccess } from './worktree-orphan-gitdir-proof'
|
||||
|
||||
export { toHostFilesystemPath, toHostRemovalPath } from './host-tree-removal'
|
||||
|
||||
@@ -14,11 +15,6 @@ export type LocalWorktreeFilesystemOptions = {
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
type LocalWorktreePathAccess = {
|
||||
statPath: StatPath
|
||||
readPath: ReadPath
|
||||
}
|
||||
|
||||
const WSL_FILE_OPERATION_TIMEOUT_MS = 30_000
|
||||
|
||||
function shouldUseWslFilesystem(options: LocalWorktreeFilesystemOptions): boolean {
|
||||
@@ -66,42 +62,55 @@ export function toLocalWorktreeRuntimePath(
|
||||
|
||||
export function getLocalWorktreePathAccess(
|
||||
options: LocalWorktreeFilesystemOptions = {}
|
||||
): LocalWorktreePathAccess {
|
||||
): WorktreeProofAccess {
|
||||
const distro = options.wslDistro?.trim()
|
||||
if (!shouldUseWslFilesystem(options) || !distro) {
|
||||
return {
|
||||
statPath: lstat,
|
||||
readPath: (path) => readFile(path, 'utf8')
|
||||
layoutStatPath: stat,
|
||||
readPath: (path) => readFile(path, 'utf8'),
|
||||
realPath: realpath
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
statPath: async (path) => {
|
||||
const target = quotePosixShell(toLinuxPath(path))
|
||||
// Shell file tests conflate permission failures with absence; stat preserves the reason.
|
||||
const stdout = await runWslCommand(distro, `LC_ALL=C stat -c %F -- ${target}`).catch(
|
||||
(error: unknown) => {
|
||||
if (isWslMissingPathError(error)) {
|
||||
throw Object.assign(new Error(`missing ${path}`), { code: 'ENOENT' })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
const kind = stdout.trim()
|
||||
return {
|
||||
type:
|
||||
kind === 'symbolic link'
|
||||
? 'symlink'
|
||||
: kind === 'regular file' || kind === 'regular empty file'
|
||||
? 'file'
|
||||
: kind === 'directory'
|
||||
? 'directory'
|
||||
: 'other'
|
||||
const inspect = (path: string, command: string) =>
|
||||
runWslCommand(distro, command).catch((error: unknown) => {
|
||||
if (isWslMissingPathError(error)) {
|
||||
throw Object.assign(new Error(`missing ${path}`), { code: 'ENOENT' })
|
||||
}
|
||||
throw error
|
||||
})
|
||||
const statPath = async (path: string, follow: boolean) => {
|
||||
const target = quotePosixShell(toLinuxPath(path))
|
||||
// Shell file tests conflate permission failures with absence; stat preserves the reason.
|
||||
const stdout = await inspect(path, `LC_ALL=C stat ${follow ? '-L ' : ''}-c %F -- ${target}`)
|
||||
const kind = stdout.trim()
|
||||
return {
|
||||
type:
|
||||
kind === 'symbolic link'
|
||||
? 'symlink'
|
||||
: kind === 'regular file' || kind === 'regular empty file'
|
||||
? 'file'
|
||||
: kind === 'directory'
|
||||
? 'directory'
|
||||
: 'other'
|
||||
}
|
||||
}
|
||||
return {
|
||||
statPath: (path) => statPath(path, false),
|
||||
layoutStatPath: (path) => statPath(path, true),
|
||||
realPath: async (path) => {
|
||||
const target = quotePosixShell(toLinuxPath(path))
|
||||
const stdout = await inspect(path, `LC_ALL=C realpath -e -- ${target}`)
|
||||
const resolved = stdout.replace(/\r?\n$/, '')
|
||||
if (!posix.isAbsolute(resolved) || /[\0\r\n]/.test(resolved)) {
|
||||
throw new Error('Invalid WSL canonical path')
|
||||
}
|
||||
return resolved
|
||||
},
|
||||
readPath: async (path) => {
|
||||
const target = quotePosixShell(toLinuxPath(path))
|
||||
const stdout = await runWslCommand(distro, `cat -- ${target}`)
|
||||
const stdout = await inspect(path, `LC_ALL=C cat -- ${target}`)
|
||||
return stdout
|
||||
}
|
||||
}
|
||||
|
||||
+66
-36
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { removeWorktree as removeNativeWorktree } from '../../git/worktree-removal'
|
||||
|
||||
import {
|
||||
MOCK_GIT_WORKTREES,
|
||||
ORIGINAL_PLATFORM,
|
||||
OrcaRuntimeService,
|
||||
WATCHER_REMOVAL_DRAIN_BUDGET_MS,
|
||||
assertWorktreeCleanForRemoval,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
listWorktrees,
|
||||
listWorktreesStrict,
|
||||
localWorktreeFilesystem,
|
||||
lstat,
|
||||
mkdir,
|
||||
removeWorktree,
|
||||
removeWorktreeLinkedPathsMock,
|
||||
@@ -256,9 +256,13 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers forced Windows runtime long-path removal and keeps skipped-hook warnings', async () => {
|
||||
it('refuses a nonempty markerless Windows folder before teardown and keeps skipped-hook warnings', async () => {
|
||||
setPlatform('win32')
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
deleteWorktreeHistoryDirMock.mockClear()
|
||||
const teardown = vi.spyOn(runtime, 'acquireFileWatcherRemoval')
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
await mkdir(TEST_WORKTREE_PATH, { recursive: true })
|
||||
await writeFile(join(TEST_WORKTREE_PATH, 'scratch.txt'), 'delete me')
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({
|
||||
@@ -281,26 +285,30 @@ describe('OrcaRuntimeService', () => {
|
||||
.mockResolvedValue([])
|
||||
|
||||
try {
|
||||
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
|
||||
|
||||
expect(result).toEqual({
|
||||
preservedBranch: { branchName: 'feature/foo', head: 'abc' },
|
||||
warning: `orca.yaml archive hook skipped for ${TEST_WORKTREE_PATH}; pass --run-hooks to run it.`
|
||||
})
|
||||
expect(gitSpy).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||||
cwd: TEST_REPO_PATH
|
||||
})
|
||||
if (ORIGINAL_PLATFORM === 'win32') {
|
||||
await expect(lstat(TEST_WORKTREE_PATH)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
}
|
||||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(TEST_WORKTREE_ID)
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
|
||||
).rejects.toThrow('is no longer a Git worktree, so its folder was left on disk.')
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
`[hooks] orca.yaml archive hook skipped for ${TEST_WORKTREE_PATH}; pass --run-hooks to run it.`
|
||||
)
|
||||
expect(teardown).not.toHaveBeenCalled()
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||||
expect(closeLocalWatcherForWorktreePathMock).not.toHaveBeenCalled()
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['worktree', 'prune'], expect.anything())
|
||||
expect(await readFile(join(TEST_WORKTREE_PATH, 'scratch.txt'), 'utf8')).toBe('delete me')
|
||||
expect(runtimeStore.getWorktreeMeta(TEST_WORKTREE_ID)).toBeDefined()
|
||||
} finally {
|
||||
warning.mockRestore()
|
||||
teardown.mockRestore()
|
||||
gitSpy.mockRestore()
|
||||
await rm(TEST_WORKTREE_PATH, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses runtime Windows recovery while Git still reports the row and keeps metadata', async () => {
|
||||
it('passes through native Windows removal failures without recursive recovery or metadata cleanup', async () => {
|
||||
setPlatform('win32')
|
||||
const removeWorktreeMeta = vi.fn()
|
||||
const runtimeStore = {
|
||||
@@ -316,17 +324,20 @@ describe('OrcaRuntimeService', () => {
|
||||
.spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath')
|
||||
.mockResolvedValue(undefined)
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(removeWorktree).mockRejectedValue(
|
||||
Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||||
})
|
||||
const path = join(TEST_WORKTREE_PATH, 'deep', 'file.txt')
|
||||
const cause = Object.assign(new Error('permission denied'), { code: 'EACCES', path })
|
||||
const failure = Object.assign(
|
||||
new Error(
|
||||
`${path} could not be deleted; it may be read-only or open in another program. Close any program using it and delete again.`,
|
||||
{ cause }
|
||||
),
|
||||
{ code: 'EACCES', path }
|
||||
)
|
||||
vi.mocked(removeWorktree).mockRejectedValue(failure)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
|
||||
).rejects.toThrow(
|
||||
`Failed to force delete worktree at ${TEST_WORKTREE_PATH}. error: failed to delete deep/file.txt: Filename too long`
|
||||
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })).rejects.toBe(
|
||||
failure
|
||||
)
|
||||
expect(removePathSpy).not.toHaveBeenCalled()
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['worktree', 'prune'], expect.anything())
|
||||
@@ -337,7 +348,7 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries stale runtime Git registration cleanup after prior filesystem recovery', async () => {
|
||||
it('releases only the missing native checkout registration and preserves its branch and host metadata policy', async () => {
|
||||
setPlatform('win32')
|
||||
const missingWorktreePath = 'C:\\workspace\\already-removed'
|
||||
const worktreeId = `${TEST_REPO_ID}::${missingWorktreePath}`
|
||||
@@ -359,12 +370,23 @@ describe('OrcaRuntimeService', () => {
|
||||
isMainWorktree: false
|
||||
}
|
||||
]
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: ''
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
|
||||
if (args[0] === 'branch') {
|
||||
throw new Error('branch is not fully merged')
|
||||
}
|
||||
return {
|
||||
stdout:
|
||||
args[0] === 'worktree' && args[1] === 'list'
|
||||
? registeredWorktrees
|
||||
.map((row) => `worktree ${row.path}\0HEAD ${row.head}\0branch ${row.branch}\0\0`)
|
||||
.join('')
|
||||
: '',
|
||||
stderr: ''
|
||||
}
|
||||
})
|
||||
vi.mocked(removeWorktree).mockImplementation(removeNativeWorktree)
|
||||
vi.mocked(listWorktrees).mockResolvedValue(registeredWorktrees)
|
||||
vi.mocked(listWorktreesStrict).mockResolvedValueOnce(registeredWorktrees).mockResolvedValue([])
|
||||
vi.mocked(listWorktreesStrict).mockResolvedValue(registeredWorktrees)
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
archive: 'pnpm worktree:archive'
|
||||
@@ -375,13 +397,21 @@ describe('OrcaRuntimeService', () => {
|
||||
const result = await runtime.removeManagedWorktree(worktreeId, { force: true })
|
||||
|
||||
expect(result).toEqual({
|
||||
preservedBranch: { branchName: 'feature/foo', head: 'abc' }
|
||||
preservedBranch: { branchName: 'feature/foo', head: 'abc' },
|
||||
warning: `orca.yaml archive hook skipped for ${missingWorktreePath}; pass --run-hooks to run it.`
|
||||
})
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
expect(gitSpy).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||||
cwd: TEST_REPO_PATH
|
||||
})
|
||||
expect(removeWorktree).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
missingWorktreePath,
|
||||
true,
|
||||
expect.objectContaining({ knownRemovedWorktree: registeredWorktrees[1] })
|
||||
)
|
||||
expect(gitSpy).toHaveBeenCalledWith(
|
||||
['-c', 'core.longpaths=true', 'worktree', 'remove', '--force', '--', missingWorktreePath],
|
||||
expect.objectContaining({ cwd: TEST_REPO_PATH, admissionExempt: true })
|
||||
)
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['worktree', 'prune'], expect.anything())
|
||||
// Host-qualified metadata purge preserves same-id rows on other hosts.
|
||||
expect(removeWorktreeMeta).toHaveBeenCalledWith(
|
||||
hostWorktreeTarget({ id: worktreeId, hostId: 'local' })
|
||||
|
||||
@@ -179,6 +179,7 @@ class RemovalRuntime extends OrcaRuntimeService {
|
||||
{ kind: 'local' },
|
||||
{ id: record.worktreeId, path: record.worktreePath },
|
||||
[],
|
||||
{},
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,10 +120,15 @@ function jobHost(purged: string[], stopPtys = vi.fn(async () => {}), pushTarget?
|
||||
/** 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<unknown> {
|
||||
const listed = await listWorktreesStrict(repoPath)
|
||||
const retried = await retryFailedRemovalUnlessRegistered(worktreeId, worktreePath, listed, () =>
|
||||
retryFailedWorktreeRemoval(worktreeId, 'local', (record) =>
|
||||
interruptedLocalWorktreeRemovalJob(record, jobHost(purged, stopPtys))
|
||||
)
|
||||
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
|
||||
|
||||
@@ -2,17 +2,36 @@ import { installRealWorkerThreadChildProcesses } from '../worker-thread-child-pr
|
||||
// Use real Git/disk evidence to finish interrupted removal regardless of how far the earlier run progressed.
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, realpath, unlink, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-lookup'
|
||||
import { buildDetectedGitWorktrees } from '../ipc/worktrees/listing/ssh-worktree-fallback'
|
||||
import { removeTree } from '../../shared/windows-transient-lock-removal'
|
||||
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 * as nativeFs from '../asar-transparent-fs'
|
||||
import * as diskFs from 'node:fs/promises'
|
||||
import * as nativeRemoval from '../git/windows-worktree-removal'
|
||||
import * as graphReader from '../git/worktree-list-reader'
|
||||
import { cleanupLocalOrphanedWorktreeDirectory } from '../local-orphaned-worktree-cleanup'
|
||||
import * as removalOwnership from '../ipc/worktrees/removal/worktree-removal-ownership'
|
||||
import { executeWorktreeRemoval } from '../ipc/worktrees/removal/execute-worktree-removal'
|
||||
import { RuntimeWorktreeRemovalInFlight } from './runtime-worktree-removal-in-flight'
|
||||
import { OrcaRuntimeWithResolveWorktreeRemovalTarget } from './workspaces/workspace-removal-target'
|
||||
import type { WorktreeIpcContext } from '../ipc/worktrees/worktree-ipc-context'
|
||||
import * as backgroundRemoval from '../worktree-background-removal'
|
||||
import * as worktreeGit from '../git/worktree'
|
||||
import * as worktreeRemote from '../ipc/worktree-remote'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import * as removalTable from '../worktree-removal-table'
|
||||
import { listWorktreesStrict } from '../git/worktree'
|
||||
import { areWorktreePathsEqual } from '../git/worktree-path-comparison'
|
||||
import {
|
||||
@@ -35,14 +54,23 @@ import {
|
||||
} from '../worktree-removal-records'
|
||||
import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal'
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
return { ...(await importOriginal<typeof diskFs>()) }
|
||||
})
|
||||
|
||||
vi.mock('../project-runtime-git-options', () => ({
|
||||
getLocalProjectWorktreeGitOptions: () => ({})
|
||||
getLocalProjectWorktreeGitOptions: vi.fn(() => ({}))
|
||||
}))
|
||||
vi.mock('../host-tree-removal', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof HostTreeRemoval>()
|
||||
return { ...actual, removeHostTree: vi.fn(actual.removeHostTree) }
|
||||
})
|
||||
|
||||
vi.mock('../ipc/worktrees/removal/worktree-removal-ownership', () => ({
|
||||
stopPtysForDestructiveWorktreeRemoval: vi.fn(async () => {}),
|
||||
removeWorktreeMetadataAndTransientState: vi.fn()
|
||||
}))
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let scratchDir = ''
|
||||
@@ -50,6 +78,7 @@ let recordsDir = ''
|
||||
let repoPath = ''
|
||||
let worktreePath = ''
|
||||
let repo: Repo
|
||||
let pushTarget: GitPushTarget | undefined
|
||||
|
||||
async function git(args: string[], cwd = repoPath): Promise<string> {
|
||||
const { stdout } = await execFileAsync('git', args, { cwd })
|
||||
@@ -64,6 +93,8 @@ async function isRegistered(path: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
pushTarget = undefined
|
||||
vi.mocked(getLocalProjectWorktreeGitOptions).mockReturnValue({})
|
||||
// realpath: macOS hands out /var/... temp paths while Git reports /private/var/....
|
||||
scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-interrupted-removal-')))
|
||||
recordsDir = join(scratchDir, 'profile')
|
||||
@@ -83,7 +114,9 @@ beforeEach(async () => {
|
||||
|
||||
afterEach(async () => {
|
||||
_resetPendingWorktreeRemovalsForTests()
|
||||
vi.restoreAllMocks()
|
||||
vi.mocked(removeHostTree).mockClear()
|
||||
vi.mocked(removalOwnership.stopPtysForDestructiveWorktreeRemoval).mockReset()
|
||||
await removeTree(scratchDir)
|
||||
})
|
||||
|
||||
@@ -92,7 +125,13 @@ type FinishOutcome =
|
||||
| { status: 'failed'; error: string }
|
||||
|
||||
async function finishAfterRestart(
|
||||
options: { repoGone?: boolean; head?: string; force?: boolean } = {}
|
||||
options: {
|
||||
repoGone?: boolean
|
||||
branch?: string
|
||||
head?: string
|
||||
force?: boolean
|
||||
stopPtys?: () => Promise<void>
|
||||
} = {}
|
||||
): Promise<{
|
||||
outcome: FinishOutcome
|
||||
purged: string[]
|
||||
@@ -104,7 +143,7 @@ async function finishAfterRestart(
|
||||
repoId: 'repo-1',
|
||||
repoPath,
|
||||
worktreePath,
|
||||
branch: 'feature',
|
||||
branch: options.branch ?? 'feature',
|
||||
head: options.head ?? (await git(['rev-parse', 'feature'])).trim(),
|
||||
deleteBranch: true,
|
||||
force: options.force ?? false,
|
||||
@@ -119,32 +158,15 @@ async function finishAfterRestart(
|
||||
expect(() => beginTerminalInstall(worktreePath)).toThrow(/being removed/)
|
||||
expect(() => beginWatcherInstall(worktreePath)).toThrow(/being removed/)
|
||||
|
||||
const storeStub = {
|
||||
getRepo: (id: string) => (id === repo.id && !options.repoGone ? repo : undefined),
|
||||
getRepos: () => (options.repoGone ? [] : [repo]),
|
||||
getWorktreeMeta: () => undefined
|
||||
}
|
||||
const purged: string[] = []
|
||||
const remember = vi.fn()
|
||||
const host = removalHost(options.repoGone, options.stopPtys)
|
||||
host.purge = ({ worktreeId }) => {
|
||||
purged.push(worktreeId)
|
||||
}
|
||||
host.preservedBranchCleanup.remember = remember
|
||||
resumeInterruptedWorktreeRemovals((interrupted) =>
|
||||
interruptedLocalWorktreeRemovalJob(interrupted, {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the finish reads only repos and worktree metadata from the store here; git options and push-target cleanup are stubbed or short-circuit without a push target.
|
||||
store: storeStub as unknown as Store,
|
||||
// Takes the real gate synchronously, as the runtime's does.
|
||||
acquireWatcherRemoval: async (path) => {
|
||||
const gate = acquireWatcherRemovalGate(path)
|
||||
expect(() => beginTerminalInstall(worktreePath)).toThrow(/being removed/)
|
||||
return { finish: async () => gate.release() }
|
||||
},
|
||||
closeWatchers: async () => {},
|
||||
preservedBranchCleanup: {
|
||||
preserveHead: (result) => result ?? {},
|
||||
remember
|
||||
},
|
||||
purge: ({ worktreeId }) => purged.push(worktreeId),
|
||||
onRemoved: () => {},
|
||||
publish: () => {}
|
||||
})
|
||||
interruptedLocalWorktreeRemovalJob(interrupted, host)
|
||||
)
|
||||
const outcome: FinishOutcome = await joined!.then(
|
||||
(result) => ({ status: 'removed' as const, ...result }),
|
||||
@@ -160,39 +182,394 @@ async function finishAfterRestart(
|
||||
|
||||
/** 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)
|
||||
const rows = await withUnregisteredRemovalCheckouts(repo.id, await listWorktreesStrict(repoPath))
|
||||
return buildDetectedGitWorktrees(removalHost().store, repo, rows)
|
||||
.filter((row) => row.visible && !row.isMainWorktree)
|
||||
.map(({ path, removalError }) => ({ path, ...(removalError ? { removalError } : {}) }))
|
||||
}
|
||||
|
||||
function removalHost(
|
||||
repoGone = false,
|
||||
stopPtys = async () => {},
|
||||
teardown = () => {}
|
||||
): Parameters<typeof interruptedLocalWorktreeRemovalJob>[1] & { store: Store } {
|
||||
const store = {
|
||||
getRepo: (id: string) => (id === repo.id && !repoGone ? repo : undefined),
|
||||
getRepos: () => (repoGone ? [] : [repo]),
|
||||
getWorktreeMeta: () => ({
|
||||
...getProjectHostSetupWorktreeMeta([], repo),
|
||||
instanceId: 'removal-fixture',
|
||||
orcaCreatedAt: 1,
|
||||
orcaCreationSource: 'runtime',
|
||||
pushTarget
|
||||
}),
|
||||
getWorktreeMetaForHost: () => store.getWorktreeMeta(),
|
||||
getSettings: () => getDefaultSettings(scratchDir),
|
||||
getProjectHostSetups: () => [],
|
||||
getAllWorktreeMeta: () => ({})
|
||||
}
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the removal/listing paths read only the supplied store methods; the fixture owns all process/session services.
|
||||
store: store as unknown as Store,
|
||||
acquireWatcherRemoval: async (path) => {
|
||||
teardown()
|
||||
const gate = acquireWatcherRemovalGate(path)
|
||||
return { finish: async () => gate.release() }
|
||||
},
|
||||
closeWatchers: vi.fn(async () => teardown()),
|
||||
stopPtys,
|
||||
preservedBranchCleanup: { preserveHead: (result) => result ?? {}, remember: vi.fn() },
|
||||
purge: () => expect(existsSync(worktreePath)).toBe(false),
|
||||
onRemoved: () => {},
|
||||
publish: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
const deletionOperations = () =>
|
||||
(['rm', 'unlink', 'rmdir'] as const).map((operation) => vi.spyOn(nativeFs, operation))
|
||||
|
||||
function expectNoTeardownOrDeletion(
|
||||
...callbacks: (ReturnType<typeof vi.fn> | ReturnType<typeof deletionOperations>[number])[]
|
||||
): void {
|
||||
callbacks.forEach((callback) => expect(callback).not.toHaveBeenCalled())
|
||||
}
|
||||
|
||||
type RemovalEntry = 'desktop' | 'runtime'
|
||||
|
||||
async function deleteViaEntry(
|
||||
entry: RemovalEntry,
|
||||
force: boolean,
|
||||
stop = async () => {},
|
||||
teardown = vi.fn()
|
||||
): Promise<unknown> {
|
||||
const worktreeId = `repo-1::${worktreePath}`
|
||||
const host = removalHost(false, stop, teardown)
|
||||
const { store } = host
|
||||
const runtime: OrcaRuntimeWithResolveWorktreeRemovalTarget = Object.assign(
|
||||
Object.create(OrcaRuntimeWithResolveWorktreeRemovalTarget.prototype),
|
||||
{
|
||||
store,
|
||||
requireStore: () => store,
|
||||
resolveWorktreeRemovalTarget: async () => ({
|
||||
id: worktreeId,
|
||||
repoId: repo.id,
|
||||
path: worktreePath
|
||||
}),
|
||||
removeManagedWorktreeInFlight: new RuntimeWorktreeRemovalInFlight(),
|
||||
localRemovalJobHost: () => host,
|
||||
acquireFileWatcherRemoval: host.acquireWatcherRemoval,
|
||||
stopPtysForDestructiveWorktreeRemoval: stop,
|
||||
closeFileWatchersForRemoval: host.closeWatchers,
|
||||
preservedBranchCleanup: { ...host.preservedBranchCleanup, delete: vi.fn() },
|
||||
purgeRemovedWorktree: host.purge,
|
||||
clearOptimisticReconcileToken: () => {},
|
||||
publishWorktreeRemovalChange: () => {},
|
||||
notifyWorktreesChanged: () => {},
|
||||
emitWorktreeLifecycle: () => {}
|
||||
}
|
||||
)
|
||||
vi.mocked(removalOwnership.stopPtysForDestructiveWorktreeRemoval).mockImplementation(stop)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: native dispatch reads only the fixture-owned context fields below.
|
||||
const context = {
|
||||
store,
|
||||
runtime,
|
||||
mainWindow: { isDestroyed: () => true }
|
||||
} as unknown as WorktreeIpcContext
|
||||
try {
|
||||
const accepted =
|
||||
entry === 'desktop'
|
||||
? await executeWorktreeRemoval(
|
||||
context,
|
||||
{ worktreeId, force },
|
||||
repo,
|
||||
repo.id,
|
||||
worktreePath,
|
||||
'local'
|
||||
)
|
||||
: await runtime.removeManagedWorktree(`id:${worktreeId}`, {
|
||||
force,
|
||||
waitForBackgroundRemoval: true
|
||||
})
|
||||
return await backgroundRemoval.finishAcceptedWorktreeRemoval(accepted, worktreeId)
|
||||
} finally {
|
||||
await _settlePendingWorktreeRemovalsForTests()
|
||||
}
|
||||
}
|
||||
|
||||
async function orphanAdmin(): Promise<void> {
|
||||
const admin = (await readFile(join(worktreePath, '.git'), 'utf8')).trim().replace(/^gitdir: /, '')
|
||||
await removeTree(admin)
|
||||
await unlink(join(worktreePath, '.git'))
|
||||
await writeFile(join(worktreePath, '.git'), ` gitdir: ${admin}\nignored second line\n`)
|
||||
}
|
||||
|
||||
async function recordFailedRemoval(force: boolean): Promise<void> {
|
||||
await writeWorktreeRemovalRecords(recordsDir, () => [
|
||||
{
|
||||
worktreeId: `repo-1::${worktreePath}`,
|
||||
repoId: repo.id,
|
||||
repoPath,
|
||||
worktreePath,
|
||||
branch: 'feature',
|
||||
head: '',
|
||||
deleteBranch: true,
|
||||
force,
|
||||
requestedAt: 1,
|
||||
failure: { message: 'held file', failedAt: 2 }
|
||||
}
|
||||
])
|
||||
await loadWorktreeRemovalRecords(recordsDir)
|
||||
}
|
||||
|
||||
describe('finishing an interrupted worktree removal after a restart', () => {
|
||||
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, records } = await finishAfterRestart({ force: true })
|
||||
|
||||
expect(outcome).toMatchObject({ status: 'removed' })
|
||||
expect(records).toEqual([])
|
||||
expect(
|
||||
outcome && 'preservedBranch' in outcome ? outcome.preservedBranch : undefined
|
||||
).toBeUndefined()
|
||||
expect(existsSync(worktreePath)).toBe(false)
|
||||
expect(await isRegistered(worktreePath)).toBe(false)
|
||||
expect(await git(['branch', '--list', 'feature'])).toBe('')
|
||||
expect(purged).toEqual([`repo-1::${worktreePath}`])
|
||||
it('refuses an unknown owner through both entries, admission, cleanup and failed listing', async () => {
|
||||
await orphanAdmin()
|
||||
const movedOwner = join(scratchDir, 'ENOENT')
|
||||
await rename(repoPath, movedOwner)
|
||||
repoPath = movedOwner
|
||||
repo.path = repoPath
|
||||
await writeFile(join(repoPath, 'HEAD'), 'ref: refs/heads/main\n')
|
||||
await writeFile(
|
||||
join(worktreePath, '.git'),
|
||||
`gitdir: ${join(repoPath, 'worktrees', 'feature')}\n`
|
||||
)
|
||||
// Successful graph seams isolate proof propagation, not a live permission exploit.
|
||||
vi.spyOn(worktreeGit, 'listWorktreesStrict').mockResolvedValue([])
|
||||
vi.spyOn(graphReader, 'readTranslatedWorktreeGraph').mockResolvedValue([])
|
||||
const stat = diskFs.stat
|
||||
vi.spyOn(diskFs, 'stat').mockImplementation(async (path, options) => {
|
||||
if (String(path) === join(repoPath, '.git')) {
|
||||
throw Object.assign(new Error(`EACCES: permission denied, stat '${String(path)}'`), {
|
||||
code: 'EACCES'
|
||||
})
|
||||
}
|
||||
return stat(path, options)
|
||||
})
|
||||
const stop = vi.fn(async () => {})
|
||||
const teardown = vi.fn()
|
||||
const operations = deletionOperations()
|
||||
await expect(
|
||||
nativeRemoval.removeWindowsCheckout(repoPath, worktreePath, true, {})
|
||||
).rejects.toThrow('Refusing to delete unregistered worktree path')
|
||||
// Reach both nonnative unregistered consumers after independently checking native admission.
|
||||
vi.spyOn(nativeRemoval, 'usesNativeWindowsRemoval').mockReturnValue(false)
|
||||
const metadata = vi.mocked(removalOwnership.removeWorktreeMetadataAndTransientState)
|
||||
metadata.mockClear()
|
||||
const pushCleanup = vi.spyOn(worktreeRemote, 'cleanupUnusedWorktreePushTargetRemote')
|
||||
for (const entry of ['desktop', 'runtime'] as const) {
|
||||
await expect(deleteViaEntry(entry, true, stop, teardown)).rejects.toThrow(
|
||||
'Refusing to delete unregistered worktree path'
|
||||
)
|
||||
}
|
||||
await expect(
|
||||
cleanupLocalOrphanedWorktreeDirectory(repoPath, worktreePath, {}, stop)
|
||||
).rejects.toThrow('Worktree is no longer registered with Git but its directory remains.')
|
||||
expectNoTeardownOrDeletion(stop, teardown, ...operations)
|
||||
expect(removeHostTree).not.toHaveBeenCalled()
|
||||
expect(metadata).not.toHaveBeenCalled()
|
||||
expect(pushCleanup).not.toHaveBeenCalled()
|
||||
expect(await readFile(join(worktreePath, 'seed.txt'), 'utf8')).toBe('seed\n')
|
||||
await recordFailedRemoval(true)
|
||||
expect(await readWorktreeRemovalRecords(recordsDir)).toMatchObject([
|
||||
{ worktreePath, failure: { message: 'held file' } }
|
||||
])
|
||||
expect(await listedRows()).toEqual([])
|
||||
await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]))
|
||||
expect(await readFile(join(worktreePath, 'seed.txt'), 'utf8')).toBe('seed\n')
|
||||
})
|
||||
|
||||
it('finishes a clean checkout with the recorded non-forced choice', async () => {
|
||||
const { outcome, purged, records } = await finishAfterRestart({ force: false })
|
||||
it.skipIf(process.platform !== 'win32').each([
|
||||
{ entry: 'desktop', phase: 'child' },
|
||||
{ entry: 'runtime', phase: 'child' },
|
||||
{ entry: 'desktop', phase: 'root' },
|
||||
{ entry: 'runtime', phase: 'root' }
|
||||
] as const)(
|
||||
'$entry completes explicit retry after releasing held $phase across restart',
|
||||
async ({ entry, phase }) => {
|
||||
const remoteUrl = 'https://example.invalid/repo.git'
|
||||
pushTarget = {
|
||||
remoteName: 'pr-contributor',
|
||||
branchName: 'feature',
|
||||
remoteCreated: true,
|
||||
remoteUrl
|
||||
}
|
||||
await git(['remote', 'add', pushTarget.remoteName, remoteUrl])
|
||||
await git(['config', `remote.${pushTarget.remoteName}.orca-created`, 'true'])
|
||||
const sibling = join(scratchDir, 'temporarily-missing')
|
||||
await git(['worktree', 'add', '-q', sibling, '-b', 'sibling'])
|
||||
await rename(sibling, join(scratchDir, 'offline'))
|
||||
const path = phase === 'child' ? join(worktreePath, 'seed.txt') : worktreePath
|
||||
const locked = Object.assign(new Error('held path'), { code: 'EBUSY', path })
|
||||
const held = vi.spyOn(nativeFs, phase === 'child' ? 'rm' : 'rmdir').mockRejectedValue(locked)
|
||||
const { outcome, records } = await finishAfterRestart({ force: true })
|
||||
const message = `${path} is open in another program. Close that program and delete again.`
|
||||
expect(outcome.status).toBe('failed')
|
||||
expect(records).toMatchObject([{ failure: { message } }])
|
||||
if (phase === 'root') {
|
||||
expect((await listWorktreesStrict(repoPath)).filter((row) => row.prunable)).toHaveLength(2)
|
||||
}
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath, removalError: message }])
|
||||
expect(await git(['remote'])).toContain(pushTarget.remoteName)
|
||||
expect(await isRegistered(worktreePath)).toBe(true)
|
||||
expect(await git(['branch', '--list', 'feature'])).not.toBe('')
|
||||
_resetPendingWorktreeRemovalsForTests()
|
||||
await loadWorktreeRemovalRecords(recordsDir)
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath, removalError: message }])
|
||||
const resume = vi.fn()
|
||||
resumeInterruptedWorktreeRemovals(resume)
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
await expect(deleteViaEntry(entry, true)).rejects.toThrow()
|
||||
expect(await isRegistered(worktreePath)).toBe(true)
|
||||
held.mockRestore()
|
||||
await deleteViaEntry(entry, true)
|
||||
expect(existsSync(worktreePath)).toBe(false)
|
||||
expect(await isRegistered(worktreePath)).toBe(false)
|
||||
expect(await git(['branch', '--list', 'feature'])).toBe('')
|
||||
expect(await git(['remote'])).not.toContain(pushTarget.remoteName)
|
||||
expect(await isRegistered(sibling)).toBe(true)
|
||||
expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([])
|
||||
},
|
||||
60_000
|
||||
)
|
||||
|
||||
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}`])
|
||||
})
|
||||
it
|
||||
.skipIf(process.platform !== 'win32')
|
||||
.each(
|
||||
(['desktop', 'runtime'] as const).flatMap((entry) =>
|
||||
['native', 'wsl'].map((route) => ({ entry, route }))
|
||||
)
|
||||
)(
|
||||
'$entry refuses $route before publication/teardown and honors current native force',
|
||||
async ({ entry, route }) => {
|
||||
const seed = await readFile(join(worktreePath, 'seed.txt'))
|
||||
await orphanAdmin()
|
||||
if (route === 'wsl') {
|
||||
await unlink(join(worktreePath, '.git'))
|
||||
vi.mocked(getLocalProjectWorktreeGitOptions).mockReturnValue({ wslDistro: 'Ubuntu' })
|
||||
// A route-list seam, not a distro run: the request must refuse before invoking a job.
|
||||
vi.spyOn(worktreeGit, 'listWorktreesStrict').mockResolvedValue([])
|
||||
}
|
||||
const retryJob = vi.spyOn(backgroundRemoval, 'retryFailedWorktreeRemoval')
|
||||
const pushCleanup = vi.spyOn(worktreeRemote, 'cleanupUnusedWorktreePushTargetRemote')
|
||||
await recordFailedRemoval(true)
|
||||
const stop = vi.fn(async () => {})
|
||||
const teardown = vi.fn()
|
||||
const operations = deletionOperations()
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await expect(deleteViaEntry(entry, false, stop, teardown)).rejects.toThrow(
|
||||
/no longer (?:registered|a Git worktree)/
|
||||
)
|
||||
expect(retryJob).not.toHaveBeenCalled()
|
||||
expect(pushCleanup).not.toHaveBeenCalled()
|
||||
expectNoTeardownOrDeletion(stop, teardown, ...operations)
|
||||
expect(await readFile(join(worktreePath, 'seed.txt'))).toEqual(seed)
|
||||
expect(await readWorktreeRemovalRecords(recordsDir)).toMatchObject([
|
||||
{ force: true, failure: { message: 'held file' } }
|
||||
])
|
||||
_resetPendingWorktreeRemovalsForTests()
|
||||
await loadWorktreeRemovalRecords(recordsDir)
|
||||
}
|
||||
if (route === 'wsl') {
|
||||
return
|
||||
}
|
||||
const sibling = join(scratchDir, 'temporarily-missing')
|
||||
await git(['worktree', 'add', '-q', sibling, '-b', 'sibling'])
|
||||
await rename(sibling, join(scratchDir, 'offline'))
|
||||
await recordFailedRemoval(false)
|
||||
const started = Promise.withResolvers<void>()
|
||||
const paused = Promise.withResolvers<void>()
|
||||
const retry = deleteViaEntry(entry, true, async () => {
|
||||
started.resolve()
|
||||
await paused.promise
|
||||
}).catch((error: unknown) => error)
|
||||
try {
|
||||
await started.promise
|
||||
expect([...removalTable.pendingWorktreeRemovals.values()]).toMatchObject([
|
||||
{ force: true, branch: 'feature', deleteBranch: true }
|
||||
])
|
||||
expect(await readWorktreeRemovalRecords(recordsDir)).toMatchObject([{ force: true }])
|
||||
} finally {
|
||||
paused.resolve()
|
||||
}
|
||||
expect(await retry).not.toBeInstanceOf(Error)
|
||||
expect(await isRegistered(worktreePath)).toBe(false)
|
||||
expect(await isRegistered(sibling)).toBe(true)
|
||||
expect(await git(['branch', '--list', 'feature'])).toBe('')
|
||||
await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'])
|
||||
await removeTree(worktreePath)
|
||||
expect(await isRegistered(worktreePath)).toBe(true)
|
||||
await deleteViaEntry(entry, true)
|
||||
expect(await isRegistered(worktreePath)).toBe(false)
|
||||
expect(await isRegistered(sibling)).toBe(true)
|
||||
expect(await git(['branch', '--list', 'feature'])).toBe('')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32').each(
|
||||
(['desktop', 'runtime'] as const).flatMap((entry) =>
|
||||
['attached', 'detached', 'folder', 'nested'].map((schedule) => ({
|
||||
entry,
|
||||
schedule
|
||||
}))
|
||||
)
|
||||
)(
|
||||
'$entry preserves $schedule replacements before/after teardown',
|
||||
async ({ entry, schedule }) => {
|
||||
await orphanAdmin()
|
||||
await loadWorktreeRemovalRecords(recordsDir)
|
||||
const change = async () => {
|
||||
if (schedule === 'nested') {
|
||||
await git(['worktree', 'add', '-q', join(worktreePath, 'nested'), '-b', 'nested'])
|
||||
} else {
|
||||
await rename(worktreePath, join(scratchDir, 'original'))
|
||||
await (schedule === 'folder'
|
||||
? mkdir(worktreePath)
|
||||
: git([
|
||||
'worktree',
|
||||
'add',
|
||||
'-q',
|
||||
...(schedule === 'detached' ? ['--detach'] : ['-b', 'other']),
|
||||
worktreePath
|
||||
]))
|
||||
}
|
||||
await writeFile(join(worktreePath, 'notes.txt'), 'mine\n')
|
||||
}
|
||||
const stop = vi.fn(change)
|
||||
const teardown = vi.fn()
|
||||
await expect(deleteViaEntry(entry, true, stop, teardown)).rejects.toThrow()
|
||||
expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n')
|
||||
expect(stop).toHaveBeenCalledOnce()
|
||||
// A second ordinary Delete refuses proofless/nested contents before teardown.
|
||||
if (['folder', 'nested'].includes(schedule)) {
|
||||
stop.mockClear()
|
||||
teardown.mockClear()
|
||||
const operations = deletionOperations()
|
||||
await expect(deleteViaEntry(entry, true, stop, teardown)).rejects.toThrow()
|
||||
expectNoTeardownOrDeletion(stop, teardown, ...operations)
|
||||
expect(await readFile(join(worktreePath, 'notes.txt'), 'utf8')).toBe('mine\n')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['partial', 'detached'])(
|
||||
'finishes the accepted %s pending checkout after restart',
|
||||
async (kind) => {
|
||||
await (kind === 'partial'
|
||||
? unlink(join(worktreePath, 'seed.txt'))
|
||||
: git(['checkout', '--detach', '-q'], worktreePath))
|
||||
const { outcome, purged, records } = await finishAfterRestart({
|
||||
force: kind === 'partial',
|
||||
branch: kind === 'detached' ? '' : 'feature',
|
||||
head: (await git(['rev-parse', 'HEAD'], worktreePath)).trim()
|
||||
})
|
||||
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'])).trim() !== '').toBe(kind === 'detached')
|
||||
expect(purged).toEqual([`repo-1::${worktreePath}`])
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the user’s non-forced choice: Git refuses local changes and the row returns as before', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
@@ -200,20 +577,23 @@ describe('finishing an interrupted worktree removal after a restart', () => {
|
||||
|
||||
const { outcome, purged, records } = await finishAfterRestart({ force: false })
|
||||
|
||||
expect(outcome).toMatchObject({ status: 'failed', error: expect.stringMatching(/--force/) })
|
||||
expect(outcome).toMatchObject({ status: 'failed' })
|
||||
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).toEqual([])
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath }])
|
||||
expect(records).toMatchObject([{ failure: { message: expect.any(String) } }])
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath, removalError: expect.any(String) }])
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'unregisters a checkout whose .git Git deleted first, keeping its files (force %s)',
|
||||
'preserves a nonempty markerless checkout and platform cleanup policy (force %s)',
|
||||
async (force) => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const admin = (await readFile(join(worktreePath, '.git'), 'utf8')).trim().slice(8)
|
||||
const backlink = await readFile(join(admin, 'gitdir'), 'utf8')
|
||||
const branchHead = await git(['rev-parse', 'feature'])
|
||||
// Git deletes in directory order; without `.git` no `worktree remove` can validate it.
|
||||
await unlink(join(worktreePath, '.git'))
|
||||
|
||||
@@ -221,12 +601,19 @@ describe('finishing an interrupted worktree removal after a restart', () => {
|
||||
|
||||
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}` })
|
||||
const native = process.platform === 'win32'
|
||||
if (native && force) {
|
||||
await expect(deleteViaEntry('runtime', force)).rejects.toThrow(refusal)
|
||||
await removalTable.persistWorktreeRemovalRecords()
|
||||
expect(await readWorktreeRemovalRecords(recordsDir)).toEqual(records)
|
||||
expect(await readFile(join(admin, 'gitdir'), 'utf8')).toBe(backlink)
|
||||
expect(await git(['rev-parse', 'feature'])).toBe(branchHead)
|
||||
}
|
||||
expect(existsSync(join(worktreePath, '.git'))).toBe(false)
|
||||
expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true)
|
||||
expect(await readFile(join(worktreePath, 'seed.txt'), 'utf8')).toBe('seed\n')
|
||||
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(await isRegistered(worktreePath)).toBe(native)
|
||||
expect((await git(['branch', '--list', 'feature'])).trim() !== '').toBe(native)
|
||||
expect(purged).toEqual([])
|
||||
expect(records).toMatchObject([{ failure: { message: refusal } }])
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath, removalError: refusal }])
|
||||
@@ -243,14 +630,14 @@ describe('finishing an interrupted worktree removal after a restart', () => {
|
||||
|
||||
const { outcome, purged, records } = await finishAfterRestart({ force: false })
|
||||
|
||||
expect(outcome).toMatchObject({ status: 'failed', error: expect.stringMatching(/--force/) })
|
||||
expect(outcome).toMatchObject({ status: 'failed' })
|
||||
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(purged).toEqual([])
|
||||
expect(records).toEqual([])
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath }])
|
||||
expect(records).toMatchObject([{ failure: { message: expect.any(String) } }])
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath, removalError: expect.any(String) }])
|
||||
})
|
||||
|
||||
it('deletes nothing in a folder put at the path after Git unregistered the checkout', async () => {
|
||||
@@ -334,7 +721,7 @@ describe('finishing an interrupted worktree removal after a restart', () => {
|
||||
// Not pruned either: the lock keeps Git's registration.
|
||||
expect(await isRegistered(worktreePath)).toBe(true)
|
||||
expect(purged).toEqual([])
|
||||
expect(records).toEqual([])
|
||||
expect(records).toMatchObject([{ failure: { message: expect.any(String) } }])
|
||||
})
|
||||
|
||||
it('deletes the branch when Git finished the checkout but the quit came before the branch', async () => {
|
||||
@@ -398,8 +785,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).toEqual([])
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath }])
|
||||
expect(records).toMatchObject([{ failure: { message: expect.any(String) } }])
|
||||
expect(await listedRows()).toEqual([{ path: worktreePath, removalError: expect.any(String) }])
|
||||
})
|
||||
|
||||
it('drops a record whose repo Orca no longer has', async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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 { RuntimeStore } from './runtime-store-contract'
|
||||
import {
|
||||
@@ -10,13 +9,19 @@ 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 { getErrorCode, normalizeLocalBranchRef } from '../git/worktree-operation-options'
|
||||
import { authorizeWindowsCheckout, usesNativeWindowsRemoval } from '../git/windows-worktree-removal'
|
||||
import {
|
||||
deleteBranchOfUnregisteredWorktree,
|
||||
finishUnregisteredWorktreeRemoval,
|
||||
removeWorktree
|
||||
} from '../git/worktree-removal'
|
||||
import { getErrorCode } 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,
|
||||
isRecordedCheckout,
|
||||
differentCheckoutAtPathError
|
||||
} from '../worktree-removal-leftover'
|
||||
import { CLIENT_REMOVAL_HOME, getPathOps } from '../worktree-removal-home-guard'
|
||||
@@ -117,8 +122,7 @@ type InterruptedLocalWorktreeRemovalArgs = Pick<
|
||||
stopSignal: AbortSignal
|
||||
}
|
||||
|
||||
/** Finish from Git/disk evidence: registered checkouts use recorded deletion choices; missing .git permits unregister only.
|
||||
* Never delete an unregistered remaining folder; once it is gone, finish the recorded cleanup. */
|
||||
/** Finish from current Git/disk evidence; native markerless roots permit only empty rmdir. */
|
||||
async function finishInterruptedLocalWorktreeRemoval(
|
||||
args: InterruptedLocalWorktreeRemovalArgs
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
@@ -161,7 +165,17 @@ async function finishInterruptedLocalWorktreeRemoval(
|
||||
if (deletable && !isRecordedCheckout(deletable, record)) {
|
||||
throw differentCheckoutAtPathError(record.worktreePath)
|
||||
}
|
||||
if (deletable && (await isCheckoutMissingGitLink(deletable.path, localOptions))) {
|
||||
const native = usesNativeWindowsRemoval(record.worktreePath, localOptions)
|
||||
const removalOptions = {
|
||||
...localOptions,
|
||||
deleteBranch: false,
|
||||
knownRemovedWorktree: {
|
||||
branch: record.branch ? `refs/heads/${record.branch}` : '',
|
||||
head: record.head
|
||||
},
|
||||
checkoutDeleteSignal: args.stopSignal
|
||||
}
|
||||
if (!native && deletable && (await isCheckoutMissingGitLink(deletable.path, localOptions))) {
|
||||
// If Git deleted .git first, it cannot remove/validate the checkout; prune registration only and keep all files for the checks below.
|
||||
await removeStaleLocalWorktreeRegistration({
|
||||
canonicalWorktreePath: deletable.path,
|
||||
@@ -173,9 +187,18 @@ async function finishInterruptedLocalWorktreeRemoval(
|
||||
deletable = undefined
|
||||
}
|
||||
// Check before teardown so refused folders retain terminals/watchers.
|
||||
if (!deletable) {
|
||||
if (!native && !deletable) {
|
||||
await assertUnregisteredCheckoutGone(record.worktreePath, worktrees)
|
||||
}
|
||||
if (native) {
|
||||
await authorizeWindowsCheckout(
|
||||
repo.path,
|
||||
record.worktreePath,
|
||||
record.force,
|
||||
removalOptions,
|
||||
true
|
||||
)
|
||||
}
|
||||
const gate = await args.acquireWatcherRemoval(record.worktreePath)
|
||||
if (args.stopPtys) {
|
||||
try {
|
||||
@@ -191,14 +214,29 @@ async function finishInterruptedLocalWorktreeRemoval(
|
||||
let result: RemoveWorktreeResult
|
||||
let removed = false
|
||||
try {
|
||||
result = await finishUnregisteredWorktreeRemoval(
|
||||
repo.path,
|
||||
record.worktreePath,
|
||||
record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null,
|
||||
// Recheck after teardown awaits in case a folder appeared meanwhile.
|
||||
() => assertUnregisteredCheckoutGone(record.worktreePath),
|
||||
localOptions
|
||||
)
|
||||
if (native) {
|
||||
await removeWorktree(repo.path, record.worktreePath, record.force, removalOptions)
|
||||
}
|
||||
const branch =
|
||||
record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null
|
||||
if (native) {
|
||||
await assertUnregisteredCheckoutGone(record.worktreePath)
|
||||
result = await deleteBranchOfUnregisteredWorktree(
|
||||
repo.path,
|
||||
record.worktreePath,
|
||||
branch,
|
||||
localOptions
|
||||
)
|
||||
} else {
|
||||
result = await finishUnregisteredWorktreeRemoval(
|
||||
repo.path,
|
||||
record.worktreePath,
|
||||
branch,
|
||||
// Recheck after teardown awaits in case a folder appeared meanwhile.
|
||||
() => assertUnregisteredCheckoutGone(record.worktreePath),
|
||||
localOptions
|
||||
)
|
||||
}
|
||||
removed = true
|
||||
} finally {
|
||||
await gate.finish(removed)
|
||||
@@ -228,10 +266,3 @@ async function isCheckoutMissingGitLink(
|
||||
return getErrorCode(error) === 'ENOENT'
|
||||
}
|
||||
}
|
||||
|
||||
function isRecordedCheckout(worktree: GitWorktreeInfo, record: WorktreeRemovalRecord): boolean {
|
||||
return (
|
||||
normalizeLocalBranchRef(worktree.branch) === record.branch &&
|
||||
(!record.head || worktree.head === record.head)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import {
|
||||
authorizeWindowsCheckout,
|
||||
nativeWindowsMarkerMissing,
|
||||
usesNativeWindowsRemoval
|
||||
} from '../git/windows-worktree-removal'
|
||||
import { pruneGitWorktrees } from '../git/worktree-prune-guard'
|
||||
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import type { ArchiveHookOverride } from '../../shared/worktree/archive-hook-removal-gate'
|
||||
@@ -84,7 +89,11 @@ async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: {
|
||||
let warning: string | undefined
|
||||
// Archive refusal precedes refresh, preflights, PTY stop and Git deletion; failure must leave them untouched (#19334).
|
||||
let archiveHookOverride: ArchiveHookOverride | undefined
|
||||
if (hooks?.scripts.archive && args.runHooks) {
|
||||
if (
|
||||
hooks?.scripts.archive &&
|
||||
args.runHooks &&
|
||||
!(await nativeWindowsMarkerMissing(canonicalPath, localOptions))
|
||||
) {
|
||||
const result = await runHook(
|
||||
'archive',
|
||||
canonicalPath,
|
||||
@@ -126,17 +135,32 @@ async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: {
|
||||
const ignoredLinkedPaths = args.force
|
||||
? []
|
||||
: await findExistingWorktreeSymlinkPaths(canonicalPath, linkedPaths)
|
||||
const markerMissing = await nativeWindowsMarkerMissing(canonicalPath, localOptions)
|
||||
try {
|
||||
await (args.hasLocalOptions
|
||||
? assertWorktreeCleanForRemoval(canonicalPath, args.force, {
|
||||
if (usesNativeWindowsRemoval(canonicalPath, localOptions)) {
|
||||
await authorizeWindowsCheckout(
|
||||
repo.path,
|
||||
canonicalPath,
|
||||
args.force,
|
||||
{
|
||||
...localOptions,
|
||||
...(ignoredLinkedPaths.length > 0 ? { ignoredUntrackedPaths: ignoredLinkedPaths } : {})
|
||||
})
|
||||
: ignoredLinkedPaths.length > 0
|
||||
knownRemovedWorktree: refreshed,
|
||||
ignoredUntrackedPaths: ignoredLinkedPaths
|
||||
},
|
||||
true
|
||||
)
|
||||
} else if (!markerMissing) {
|
||||
await (args.hasLocalOptions
|
||||
? assertWorktreeCleanForRemoval(canonicalPath, args.force, {
|
||||
ignoredUntrackedPaths: ignoredLinkedPaths
|
||||
...localOptions,
|
||||
...(ignoredLinkedPaths.length > 0 ? { ignoredUntrackedPaths: ignoredLinkedPaths } : {})
|
||||
})
|
||||
: assertWorktreeCleanForRemoval(canonicalPath, args.force))
|
||||
: ignoredLinkedPaths.length > 0
|
||||
? assertWorktreeCleanForRemoval(canonicalPath, args.force, {
|
||||
ignoredUntrackedPaths: ignoredLinkedPaths
|
||||
})
|
||||
: assertWorktreeCleanForRemoval(canonicalPath, args.force))
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isOrphanCompatiblePreflightError(error)) {
|
||||
throw new Error(formatWorktreeRemovalError(error, canonicalPath, args.force))
|
||||
@@ -147,7 +171,7 @@ async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: {
|
||||
let accepted = false
|
||||
try {
|
||||
await args.stopPtys()
|
||||
if (linkedPaths.length > 0) {
|
||||
if (!markerMissing && linkedPaths.length > 0) {
|
||||
await removeWorktreeLinkedPaths(canonicalPath, linkedPaths)
|
||||
}
|
||||
accepted = true
|
||||
@@ -222,6 +246,9 @@ export async function finishRuntimeLocalWorktreeRemoval(
|
||||
refreshed.head
|
||||
)
|
||||
} catch (error) {
|
||||
if (usesNativeWindowsRemoval(canonicalPath, localOptions)) {
|
||||
throw error
|
||||
}
|
||||
const recovered = await recoverLocalWindowsWorktreeRemoval({
|
||||
error,
|
||||
force: args.force,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { removeWorktree } from '../git/worktree-removal'
|
||||
import { usesNativeWindowsRemoval } from '../git/windows-worktree-removal'
|
||||
import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
@@ -53,15 +55,16 @@ export async function removeRuntimeUnregisteredWorktree(args: {
|
||||
toLocalWorktreeRuntimePath(target.path, args.localOptions),
|
||||
toLocalWorktreeRuntimePath(repo.path, args.localOptions),
|
||||
removalHome,
|
||||
access.statPath,
|
||||
access.readPath
|
||||
access
|
||||
))
|
||||
}
|
||||
if (canCleanOrphanedDirectory) {
|
||||
const native = usesNativeWindowsRemoval(target.path, args.localOptions)
|
||||
if (canCleanOrphanedDirectory || (native && removedMeta)) {
|
||||
assertWorktreeDoesNotContainRegisteredWorktree(target.path, registeredWorktrees)
|
||||
if (!args.force) {
|
||||
if (canCleanOrphanedDirectory && !args.force) {
|
||||
throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE)
|
||||
}
|
||||
|
||||
await deleteUnregisteredDirectory(args)
|
||||
args.finishRemoval()
|
||||
return {}
|
||||
@@ -84,7 +87,13 @@ async function deleteUnregisteredDirectory(
|
||||
let completed = false
|
||||
try {
|
||||
await args.stopPtys(args.target.id, args.allowUnverifiedPtyStop)
|
||||
await removeLocalWorktreePath(args.target.path, args.localOptions)
|
||||
await (usesNativeWindowsRemoval(args.target.path, args.localOptions)
|
||||
? removeWorktree(args.repo.path, args.target.path, args.force, {
|
||||
...args.localOptions,
|
||||
deleteBranch: false,
|
||||
knownRemovedWorktree: null
|
||||
})
|
||||
: removeLocalWorktreePath(args.target.path, args.localOptions))
|
||||
completed = true
|
||||
} finally {
|
||||
await gate.finish(completed)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { GitWorktreeInfo } from '../../../shared/worktree/types'
|
||||
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
|
||||
import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options'
|
||||
import { assertWorktreeUnlockedForRemoval } from '../../../shared/worktree/removal'
|
||||
import { formatWorktreeRemovalError } from '../../ipc/worktree-logic'
|
||||
import { usesNativeWindowsRemoval } from '../../git/windows-worktree-removal'
|
||||
import { isPrunableGitFileWorktree } from '../../worktree-prunable-git-file'
|
||||
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
|
||||
import { isLocalRuntimeWorktreePathMissing } from '../runtime-worktree-filesystem'
|
||||
|
||||
export async function isStaleLocalWorktreeRegistration(
|
||||
registeredWorktree: GitWorktreeInfo,
|
||||
force: boolean,
|
||||
removedMeta: WorktreeMeta | undefined,
|
||||
localWorktreeGitOptions: LocalProjectWorktreeGitOptions
|
||||
): Promise<boolean | undefined> {
|
||||
const canonicalWorktreePath = registeredWorktree.path
|
||||
try {
|
||||
assertWorktreeUnlockedForRemoval(registeredWorktree)
|
||||
} catch (error) {
|
||||
throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force))
|
||||
}
|
||||
return (
|
||||
!usesNativeWindowsRemoval(canonicalWorktreePath, localWorktreeGitOptions) &&
|
||||
((await isPrunableGitFileWorktree(registeredWorktree, localWorktreeGitOptions)) ||
|
||||
(force === true &&
|
||||
process.platform === 'win32' &&
|
||||
(isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) &&
|
||||
removedMeta &&
|
||||
(await isLocalRuntimeWorktreePathMissing(canonicalWorktreePath, localWorktreeGitOptions))))
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
RuntimeWorktreeRemovalTarget
|
||||
} from '../runtime-worktree-selection'
|
||||
import { resolveRuntimeWorktreeRemovalTarget } from '../runtime-worktree-removal-target'
|
||||
import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options'
|
||||
import type { RuntimeStore } from '../runtime-store-contract'
|
||||
import { splitWorktreeId, worktreeIdsEqual } from '../../../shared/worktree/id'
|
||||
import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner'
|
||||
@@ -87,12 +88,13 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu
|
||||
return waitForPendingWorktreeRemoval(worktreeId, parseExecutionHostId(options.hostId)?.id)
|
||||
}
|
||||
|
||||
/** Finish a failed local removal only while Git still omits the path and the directory is gone; refuse while it exists. */
|
||||
/** Failed intent schedules a retry; current Git/filesystem proof still controls every deletion. */
|
||||
protected async retryFailedRemoval(
|
||||
route: { kind: string },
|
||||
target: { id: string; path: string },
|
||||
registeredWorktrees: readonly GitWorktreeInfo[],
|
||||
options: RemoveManagedWorktreeOptions
|
||||
options: RemoveManagedWorktreeOptions,
|
||||
localOptions: LocalProjectWorktreeGitOptions
|
||||
): Promise<boolean> {
|
||||
const store = this.store
|
||||
if (route.kind !== 'local' || !store) {
|
||||
@@ -100,16 +102,25 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu
|
||||
}
|
||||
const hostId = parseExecutionHostId(options.hostId)?.id
|
||||
const allowUnverifiedPtyStop = options.allowUnverifiedPtyStop === true
|
||||
return retryFailedRemovalUnlessRegistered(target.id, target.path, registeredWorktrees, () =>
|
||||
retryFailedWorktreeRemoval(target.id, hostId, (record) =>
|
||||
interruptedLocalWorktreeRemovalJob(record, {
|
||||
...this.localRemovalJobHost(store),
|
||||
stopPtys: () =>
|
||||
this.stopPtysForDestructiveWorktreeRemoval(record.worktreeId, {
|
||||
allowUnverifiedStop: allowUnverifiedPtyStop
|
||||
})
|
||||
})
|
||||
)
|
||||
return retryFailedRemovalUnlessRegistered(
|
||||
target.id,
|
||||
target.path,
|
||||
registeredWorktrees,
|
||||
localOptions,
|
||||
() =>
|
||||
retryFailedWorktreeRemoval(
|
||||
target.id,
|
||||
hostId,
|
||||
(record) =>
|
||||
interruptedLocalWorktreeRemovalJob(record, {
|
||||
...this.localRemovalJobHost(store),
|
||||
stopPtys: () =>
|
||||
this.stopPtysForDestructiveWorktreeRemoval(record.worktreeId, {
|
||||
allowUnverifiedStop: allowUnverifiedPtyStop
|
||||
})
|
||||
}),
|
||||
options.force === true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options'
|
||||
import { isStaleLocalWorktreeRegistration } from './stale-worktree-registration-preflight'
|
||||
import type { RuntimeStore } from '../runtime-store-contract'
|
||||
import type { GitWorktreeInfo } from '../../../shared/worktree/types'
|
||||
import { OrcaRuntimeWithCreateManagedRemoteWorktree } from './workspace-metadata'
|
||||
@@ -24,13 +26,9 @@ import {
|
||||
resolveWorktreeRemovalRoute
|
||||
} from '../../worktree-removal-execution-host-route'
|
||||
import { listWorktreesOnRemovalRoute } from '../worktree-removal-route-listing'
|
||||
import { isPrunableGitFileWorktree } from '../../worktree-prunable-git-file'
|
||||
import { authorizeUnregisteredWindowsRemoval } from '../../git/windows-worktree-removal'
|
||||
import { findRegisteredDeletableWorktree } from '../../worktree-removal-safety'
|
||||
import { removeRuntimeUnregisteredWorktree } from '../runtime-unregistered-worktree-removal'
|
||||
import { assertWorktreeUnlockedForRemoval } from '../../../shared/worktree/removal'
|
||||
import { formatWorktreeRemovalError } from '../../ipc/worktree-logic'
|
||||
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
|
||||
import { isLocalRuntimeWorktreePathMissing } from '../runtime-worktree-filesystem'
|
||||
import { removeStaleLocalWorktreeRegistration } from '../../local-worktree-removal-recovery'
|
||||
import { cleanupUnusedWorktreePushTargetRemote } from '../../ipc/worktree-remote'
|
||||
import { removeRuntimeRegisteredLocalWorktree } from '../runtime-registered-local-worktree-removal'
|
||||
@@ -64,7 +62,8 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
route: { kind: string },
|
||||
target: { id: string; path: string },
|
||||
registeredWorktrees: readonly GitWorktreeInfo[],
|
||||
options: RemoveManagedWorktreeOptions
|
||||
options: RemoveManagedWorktreeOptions,
|
||||
localOptions: LocalProjectWorktreeGitOptions
|
||||
): Promise<boolean>
|
||||
|
||||
async removeManagedWorktree(
|
||||
@@ -170,7 +169,22 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
registeredWorktrees,
|
||||
removalHome
|
||||
)
|
||||
if (await this.retryFailedRemoval(route, removalTarget, registeredWorktrees, options)) {
|
||||
await authorizeUnregisteredWindowsRemoval(
|
||||
!!registeredWorktree,
|
||||
repo!.path,
|
||||
removalTarget.path,
|
||||
force,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
if (
|
||||
await this.retryFailedRemoval(
|
||||
route,
|
||||
removalTarget,
|
||||
registeredWorktrees,
|
||||
options,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
) {
|
||||
return { removing: true }
|
||||
}
|
||||
if (!registeredWorktree) {
|
||||
@@ -199,22 +213,13 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
}
|
||||
const canonicalWorktreePath = registeredWorktree.path
|
||||
const deleteBranch = removedMeta?.preserveBranchOnDelete !== true
|
||||
try {
|
||||
assertWorktreeUnlockedForRemoval(registeredWorktree)
|
||||
} catch (error) {
|
||||
throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force))
|
||||
}
|
||||
if (
|
||||
(await isPrunableGitFileWorktree(registeredWorktree, localWorktreeGitOptions)) ||
|
||||
(force === true &&
|
||||
process.platform === 'win32' &&
|
||||
(isWindowsAbsolutePathLike(canonicalWorktreePath) ||
|
||||
!!localWorktreeGitOptions.wslDistro) &&
|
||||
removedMeta &&
|
||||
(await isLocalRuntimeWorktreePathMissing(
|
||||
canonicalWorktreePath,
|
||||
localWorktreeGitOptions
|
||||
)))
|
||||
await isStaleLocalWorktreeRegistration(
|
||||
registeredWorktree,
|
||||
force,
|
||||
removedMeta,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
) {
|
||||
const removalResult = await removeStaleLocalWorktreeRegistration({
|
||||
canonicalWorktreePath,
|
||||
|
||||
@@ -149,7 +149,8 @@ export function startBackgroundWorktreeRemoval(
|
||||
export function retryFailedWorktreeRemoval(
|
||||
worktreeId: string,
|
||||
hostId: ExecutionHostId | undefined,
|
||||
jobFor: (record: WorktreeRemovalRecord) => BackgroundWorktreeRemovalJob
|
||||
jobFor: (record: WorktreeRemovalRecord) => BackgroundWorktreeRemovalJob,
|
||||
force?: boolean
|
||||
): Promise<RemoveWorktreeResult> | undefined {
|
||||
const failed =
|
||||
(hostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID
|
||||
@@ -158,7 +159,8 @@ export function retryFailedWorktreeRemoval(
|
||||
if (!failed) {
|
||||
return waitForPendingWorktreeRemoval(worktreeId, hostId)
|
||||
}
|
||||
const { failure: _failure, ...record } = failed
|
||||
const { failure: _failure, ...original } = failed
|
||||
const record = { ...original, force: force ?? original.force }
|
||||
const settlement = addPendingRemoval(record)
|
||||
const job = jobFor(record)
|
||||
const leftoverOnly: BackgroundWorktreeRemovalJob = {
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
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'
|
||||
|
||||
@@ -167,19 +166,24 @@ describe('a delete that fails after Git dropped the registration', () => {
|
||||
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([])
|
||||
expect(deleteBranchOfUnregisteredWorktree).not.toHaveBeenCalled()
|
||||
expect(cleanupPushTargetRemote).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['branch', 'head'])(
|
||||
'retires a stale registered failure by %s without cleanup',
|
||||
async (kind) => {
|
||||
const original = { ...leftoverRow(), removalError: undefined }
|
||||
vi.mocked(listWorktreesStrict).mockResolvedValue([mainWorktree, original])
|
||||
const cleanupPushTargetRemote = vi.fn(async () => {})
|
||||
await expect(startFailingRemoval({ cleanupPushTargetRemote })).rejects.toThrow(GIT_ERROR)
|
||||
await _settlePendingWorktreeRemovalsForTests()
|
||||
expect(await withUnregisteredRemovalCheckouts('repo-1', [original])).toEqual([leftoverRow()])
|
||||
const replacement = { ...original, [kind]: kind === 'branch' ? 'refs/heads/other' : 'def' }
|
||||
expect(await withUnregisteredRemovalCheckouts('repo-1', [replacement])).toEqual([replacement])
|
||||
await vi.waitFor(async () =>
|
||||
expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([])
|
||||
)
|
||||
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 () => {})
|
||||
@@ -277,6 +281,7 @@ describe('a delete that fails after Git dropped the registration', () => {
|
||||
|
||||
it('does not run the recorded removal once Git registers a checkout at the path again', async () => {
|
||||
await failRemoval()
|
||||
const [record] = await readWorktreeRemovalRecords(join(directory, 'profile'))
|
||||
vi.mocked(listWorktreesStrict).mockResolvedValue([
|
||||
mainWorktree,
|
||||
{ ...leftoverRow(), removalError: undefined }
|
||||
@@ -288,10 +293,13 @@ describe('a delete that fails after Git dropped the registration', () => {
|
||||
publish: () => {}
|
||||
}))
|
||||
|
||||
await expect(retried).rejects.toThrow(/A different checkout is now at/)
|
||||
const message = `A different checkout is now at ${checkout}; Orca left it in place. Delete it again to remove it.`
|
||||
await expect(retried).rejects.toThrow(message)
|
||||
await _settlePendingWorktreeRemovalsForTests()
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([])
|
||||
expect(await readWorktreeRemovalRecords(join(directory, 'profile'))).toEqual([
|
||||
{ ...record, failure: { message, failedAt: expect.any(Number) } }
|
||||
])
|
||||
})
|
||||
|
||||
it('is not retried for another host', async () => {
|
||||
@@ -361,47 +369,4 @@ 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(
|
||||
`${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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { type posix, win32 } from 'node:path'
|
||||
import type { posix } from 'node:path'
|
||||
import { isDefinitiveAbsence } from '../shared/definitive-filesystem-absence'
|
||||
import { resolveGitCommonDirectory } from '../shared/git-common-directory'
|
||||
import {
|
||||
areResolvedPathsEqual,
|
||||
canonicalOwnerPathsEqual,
|
||||
isDirectWorktreeAdmin
|
||||
} from './worktree-owner-path-equivalence'
|
||||
|
||||
type PathOps = typeof posix
|
||||
export type StatPath = (path: string) => Promise<unknown>
|
||||
export type ReadPath = (path: string) => Promise<unknown>
|
||||
|
||||
// Only owning-layout discovery follows links; candidate and admin-entry proof do not.
|
||||
export type WorktreeProofAccess = {
|
||||
statPath: StatPath
|
||||
layoutStatPath: StatPath
|
||||
readPath: ReadPath
|
||||
realPath: (path: string) => Promise<string>
|
||||
}
|
||||
|
||||
export async function gitFileProvesOrphanedWorktreeDirectory(args: {
|
||||
gitFilePath: string
|
||||
worktreePath: string
|
||||
repoPath: string
|
||||
pathOps: PathOps
|
||||
statPath: StatPath
|
||||
readPath: ReadPath
|
||||
access: WorktreeProofAccess
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const gitEntry = await args.statPath(args.gitFilePath)
|
||||
const gitEntry = await args.access.statPath(args.gitFilePath)
|
||||
if (!isGitFileStat(gitEntry)) {
|
||||
return false
|
||||
}
|
||||
const gitFileContents = readFileResultToText(await args.readPath(args.gitFilePath))
|
||||
const gitFileContents = readFileResultToText(await args.access.readPath(args.gitFilePath))
|
||||
return gitFileContents
|
||||
? gitFilePointsAtRepoWorktree(
|
||||
? await gitFilePointsAtRepoWorktree(
|
||||
gitFileContents,
|
||||
args.worktreePath,
|
||||
args.repoPath,
|
||||
args.pathOps,
|
||||
args.statPath,
|
||||
args.readPath
|
||||
args.access
|
||||
)
|
||||
: false
|
||||
} catch {
|
||||
@@ -65,65 +78,96 @@ function resolveGitdirPath(gitdirPath: string, basePath: string, pathOps: PathOp
|
||||
: pathOps.resolve(basePath, gitdirPath)
|
||||
}
|
||||
|
||||
function areResolvedPathsEqual(leftPath: string, rightPath: string, pathOps: PathOps): boolean {
|
||||
const left = pathOps.normalize(pathOps.resolve(leftPath))
|
||||
const right = pathOps.normalize(pathOps.resolve(rightPath))
|
||||
return pathOps === win32 ? left.toLowerCase() === right.toLowerCase() : left === right
|
||||
}
|
||||
|
||||
async function resolveRepoWorktreesPath(
|
||||
async function readProvenRepoWorktreesPath(
|
||||
repoPath: string,
|
||||
pathOps: PathOps,
|
||||
statPath: StatPath,
|
||||
readPath: ReadPath
|
||||
access: WorktreeProofAccess
|
||||
): Promise<string | null> {
|
||||
const repoGitPath = pathOps.join(repoPath, '.git')
|
||||
try {
|
||||
const repoGitEntry = await statPath(repoGitPath)
|
||||
if (!isGitFileStat(repoGitEntry)) {
|
||||
return pathOps.resolve(repoPath, '.git', 'worktrees')
|
||||
let owningGitDir: string | null = null
|
||||
const common = await resolveGitCommonDirectory(
|
||||
repoPath,
|
||||
{},
|
||||
{
|
||||
statPath: async (file) => {
|
||||
const entry = await access.layoutStatPath(file)
|
||||
return {
|
||||
isFile: () => isGitFileStat(entry),
|
||||
isDirectory: () => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
('isDirectory' in entry &&
|
||||
typeof entry.isDirectory === 'function' &&
|
||||
entry.isDirectory() === true) ||
|
||||
('type' in entry && entry.type === 'directory')
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
readPath: async (file) => {
|
||||
try {
|
||||
const text = readFileResultToText(await access.readPath(file))
|
||||
if (text === null) {
|
||||
throw new Error('Cannot read Git metadata')
|
||||
}
|
||||
return text
|
||||
} catch (error) {
|
||||
if (isDefinitiveAbsence(error)) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
join: pathOps.join,
|
||||
resolvePath: (base, pointer) => resolveGitdirPath(pointer, base, pathOps),
|
||||
parseMarker: (contents) => {
|
||||
const marker = parseGitdirPath(contents)
|
||||
owningGitDir = marker ? resolveGitdirPath(marker, repoPath, pathOps) : null
|
||||
return marker
|
||||
},
|
||||
isMissing: isDefinitiveAbsence
|
||||
}
|
||||
const repoGitContents = readFileResultToText(await readPath(repoGitPath))
|
||||
const repoGitdirPath = repoGitContents ? parseGitdirPath(repoGitContents) : null
|
||||
if (!repoGitdirPath) {
|
||||
return null
|
||||
}
|
||||
const resolvedRepoGitdirPath = resolveGitdirPath(repoGitdirPath, repoPath, pathOps)
|
||||
const parentPath = pathOps.dirname(resolvedRepoGitdirPath)
|
||||
if (
|
||||
pathOps.basename(parentPath) === 'worktrees' &&
|
||||
(await repoGitdirIsLinkedWorktreeAdminEntry(
|
||||
resolvedRepoGitdirPath,
|
||||
repoPath,
|
||||
pathOps,
|
||||
readPath
|
||||
))
|
||||
) {
|
||||
return parentPath
|
||||
}
|
||||
return pathOps.join(resolvedRepoGitdirPath, 'worktrees')
|
||||
} catch {
|
||||
)
|
||||
if (!common) {
|
||||
return null
|
||||
}
|
||||
const worktrees = pathOps.join(common, 'worktrees')
|
||||
if (
|
||||
owningGitDir &&
|
||||
(await isDirectWorktreeAdmin(owningGitDir, worktrees, pathOps, access)) &&
|
||||
!(await repoGitdirIsLinkedWorktreeAdminEntry(owningGitDir, repoPath, pathOps, access))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return worktrees
|
||||
}
|
||||
|
||||
async function repoGitdirIsLinkedWorktreeAdminEntry(
|
||||
resolvedRepoGitdirPath: string,
|
||||
repoPath: string,
|
||||
pathOps: PathOps,
|
||||
readPath: ReadPath
|
||||
access: WorktreeProofAccess
|
||||
): Promise<boolean> {
|
||||
const adminGitdirPath = pathOps.join(resolvedRepoGitdirPath, 'gitdir')
|
||||
try {
|
||||
const adminGitdirContents = readFileResultToText(await readPath(adminGitdirPath))
|
||||
const adminGitdirContents = readFileResultToText(await access.readPath(adminGitdirPath))
|
||||
const repoGitPath = adminGitdirContents ? parseFirstLinePath(adminGitdirContents) : null
|
||||
if (!repoGitPath) {
|
||||
return false
|
||||
}
|
||||
const resolvedRepoGitPath = resolveGitdirPath(repoGitPath, resolvedRepoGitdirPath, pathOps)
|
||||
const resolvedExpectedGitPath = pathOps.resolve(repoPath, '.git')
|
||||
// Only the admin backlink distinguishes a linked checkout from a separate git dir named worktrees.
|
||||
return areResolvedPathsEqual(resolvedRepoGitPath, resolvedExpectedGitPath, pathOps)
|
||||
// A linked parent must prove its own backlink before supplying this namespace.
|
||||
return (
|
||||
areResolvedPathsEqual(resolvedRepoGitPath, resolvedExpectedGitPath, pathOps) ||
|
||||
(await canonicalOwnerPathsEqual(
|
||||
resolvedRepoGitPath,
|
||||
resolvedExpectedGitPath,
|
||||
pathOps,
|
||||
access
|
||||
))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -134,23 +178,27 @@ async function gitFilePointsAtRepoWorktree(
|
||||
worktreePath: string,
|
||||
repoPath: string,
|
||||
pathOps: PathOps,
|
||||
statPath: StatPath,
|
||||
readPath: ReadPath
|
||||
access: WorktreeProofAccess
|
||||
): Promise<boolean> {
|
||||
const gitdirPath = parseGitdirPath(contents)
|
||||
if (!gitdirPath) {
|
||||
return false
|
||||
}
|
||||
const resolvedGitdirPath = resolveGitdirPath(gitdirPath, worktreePath, pathOps)
|
||||
const repoWorktreesPath = await resolveRepoWorktreesPath(repoPath, pathOps, statPath, readPath)
|
||||
if (!repoWorktreesPath || !containsPath(repoWorktreesPath, resolvedGitdirPath, pathOps)) {
|
||||
const repoWorktreesPath = await readProvenRepoWorktreesPath(repoPath, pathOps, access)
|
||||
if (
|
||||
!repoWorktreesPath ||
|
||||
!(await isDirectWorktreeAdmin(resolvedGitdirPath, repoWorktreesPath, pathOps, access))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const relativeGitdirPath = pathOps.relative(repoWorktreesPath, resolvedGitdirPath)
|
||||
if (relativeGitdirPath === '' || relativeGitdirPath.includes(pathOps.sep)) {
|
||||
return false
|
||||
}
|
||||
return adminGitdirPointsAtCandidate(resolvedGitdirPath, worktreePath, pathOps, statPath, readPath)
|
||||
return adminGitdirPointsAtCandidate(
|
||||
resolvedGitdirPath,
|
||||
worktreePath,
|
||||
pathOps,
|
||||
access.statPath,
|
||||
access.readPath
|
||||
)
|
||||
}
|
||||
|
||||
async function adminGitdirPointsAtCandidate(
|
||||
@@ -176,30 +224,18 @@ async function adminGitdirPointsAtCandidate(
|
||||
// A copied .git file can name another checkout; verify the admin entry's backlink.
|
||||
return areResolvedPathsEqual(resolvedCandidateGitPath, resolvedExpectedGitPath, pathOps)
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) {
|
||||
if (!isDefinitiveAbsence(error)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await statPath(resolvedGitdirPath)
|
||||
return false
|
||||
} catch (statError) {
|
||||
return isMissingPathError(statError)
|
||||
return isDefinitiveAbsence(statError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function containsPath(parentPath: string, childPath: string, pathOps: PathOps): boolean {
|
||||
const relativePath = pathOps.relative(parentPath, childPath)
|
||||
// ..name is a child name; only .. and ../... escape.
|
||||
return (
|
||||
relativePath === '' ||
|
||||
(!!relativePath &&
|
||||
relativePath !== '..' &&
|
||||
!relativePath.startsWith(`..${pathOps.sep}`) &&
|
||||
!pathOps.isAbsolute(relativePath))
|
||||
)
|
||||
}
|
||||
|
||||
function parseGitdirPath(contents: string): string | null {
|
||||
const firstLine = contents.split(/\r?\n/, 1)[0]?.trim()
|
||||
if (!firstLine) {
|
||||
@@ -214,24 +250,3 @@ function parseFirstLinePath(contents: string): string | null {
|
||||
const firstLine = contents.split(/\r?\n/, 1)[0]?.trim()
|
||||
return firstLine || null
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? String((error as NodeJS.ErrnoException).code)
|
||||
: undefined
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return true
|
||||
}
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: error && typeof error === 'object' && 'message' in error
|
||||
? String((error as { message: unknown }).message)
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: ''
|
||||
return /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test(
|
||||
message
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type posix, win32 } from 'node:path'
|
||||
import { isDefinitiveAbsence } from '../shared/definitive-filesystem-absence'
|
||||
import type { WorktreeProofAccess } from './worktree-orphan-gitdir-proof'
|
||||
|
||||
type PathOps = typeof posix
|
||||
|
||||
export function areResolvedPathsEqual(
|
||||
leftPath: string,
|
||||
rightPath: string,
|
||||
pathOps: PathOps
|
||||
): boolean {
|
||||
const left = pathOps.normalize(pathOps.resolve(leftPath))
|
||||
const right = pathOps.normalize(pathOps.resolve(rightPath))
|
||||
return pathOps === win32 ? left.toLowerCase() === right.toLowerCase() : left === right
|
||||
}
|
||||
|
||||
async function readOwnerCanonicalPath(
|
||||
path: string,
|
||||
pathOps: PathOps,
|
||||
access: WorktreeProofAccess
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const resolved = await access.realPath(path)
|
||||
if (typeof resolved !== 'string' || !pathOps.isAbsolute(resolved) || resolved.includes('\0')) {
|
||||
throw new Error('Invalid owning-host canonical path')
|
||||
}
|
||||
return resolved
|
||||
} catch (error) {
|
||||
if (isDefinitiveAbsence(error)) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function canonicalOwnerPathsEqual(
|
||||
left: string,
|
||||
right: string,
|
||||
pathOps: PathOps,
|
||||
access: WorktreeProofAccess
|
||||
): Promise<boolean> {
|
||||
const [a, b] = await Promise.all([
|
||||
readOwnerCanonicalPath(left, pathOps, access),
|
||||
readOwnerCanonicalPath(right, pathOps, access)
|
||||
])
|
||||
return a !== null && b !== null && areResolvedPathsEqual(a, b, pathOps)
|
||||
}
|
||||
|
||||
export async function isDirectWorktreeAdmin(
|
||||
admin: string,
|
||||
worktrees: string,
|
||||
pathOps: PathOps,
|
||||
access: WorktreeProofAccess
|
||||
): Promise<boolean> {
|
||||
const parent = pathOps.dirname(admin)
|
||||
if (
|
||||
areResolvedPathsEqual(admin, parent, pathOps) ||
|
||||
areResolvedPathsEqual(admin, worktrees, pathOps)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (areResolvedPathsEqual(parent, worktrees, pathOps)) {
|
||||
return true
|
||||
}
|
||||
const [actual, expected] = await Promise.all([
|
||||
readOwnerCanonicalPath(parent, pathOps, access),
|
||||
readOwnerCanonicalPath(worktrees, pathOps, access)
|
||||
])
|
||||
if (actual !== null || expected !== null) {
|
||||
return actual !== null && expected !== null && areResolvedPathsEqual(actual, expected, pathOps)
|
||||
}
|
||||
const name = pathOps.basename(parent)
|
||||
if ((pathOps === win32 ? name.toLowerCase() : name) !== 'worktrees') {
|
||||
return false
|
||||
}
|
||||
// Missing worktrees directories prove namespace only; the admin must still prove absent.
|
||||
return canonicalOwnerPathsEqual(
|
||||
pathOps.dirname(parent),
|
||||
pathOps.dirname(worktrees),
|
||||
pathOps,
|
||||
access
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,42 @@
|
||||
import { deleteBranchOfUnregisteredWorktree } from './git/worktree-removal'
|
||||
import { isCheckoutRegistered, isUnregisteredRemovalLeftover } from './worktree-removal-leftover'
|
||||
import {
|
||||
isRecordedCheckout,
|
||||
registeredRemovalCheckout,
|
||||
isUnregisteredRemovalLeftover
|
||||
} from './worktree-removal-leftover'
|
||||
import type { WorktreeRemovalFailure, WorktreeRemovalRecord } from './worktree-removal-records'
|
||||
import { worktreeCheckoutExists } from './worktree-removal-table'
|
||||
|
||||
/** Keep the error if Git dropped registration but left the checkout on disk. */
|
||||
/** Retain failures for the same checkout without cleaning up its live Git ownership. */
|
||||
export async function failureLeftByGit(
|
||||
record: WorktreeRemovalRecord,
|
||||
error: unknown,
|
||||
cleanupPushTargetRemote: (() => Promise<void>) | undefined
|
||||
): Promise<WorktreeRemovalFailure | undefined> {
|
||||
if (!(await isCheckoutLeftUnregistered(record))) {
|
||||
if (!(await worktreeCheckoutExists(record.worktreePath))) {
|
||||
return undefined
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const failure = {
|
||||
message: (error instanceof Error ? error.message : String(error)).replace(
|
||||
message: message.replace(
|
||||
/ Close (?:that program|any program using it) and delete again\.$/,
|
||||
() =>
|
||||
` Close any program using it. You may need to delete the remaining folder at ${record.worktreePath} manually to finish removing this workspace.`
|
||||
),
|
||||
failedAt: Date.now()
|
||||
}
|
||||
try {
|
||||
const registered = await registeredRemovalCheckout(record)
|
||||
if (registered) {
|
||||
return isRecordedCheckout(registered, record) ? { ...failure, message } : undefined
|
||||
}
|
||||
if (!(await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath))) {
|
||||
return undefined
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[worktrees] could not inspect failed removal of ${record.worktreePath}`, error)
|
||||
return failure
|
||||
}
|
||||
await finishGitSideOfFailedRemoval(record, cleanupPushTargetRemote)
|
||||
return failure
|
||||
}
|
||||
@@ -45,18 +62,3 @@ async function finishGitSideOfFailedRemoval(
|
||||
console.warn(`[worktrees] could not clean up the push target of ${record.worktreePath}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async function isCheckoutLeftUnregistered(record: WorktreeRemovalRecord): Promise<boolean> {
|
||||
if (!(await worktreeCheckoutExists(record.worktreePath))) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return (
|
||||
!(await isCheckoutRegistered(record)) &&
|
||||
(await isUnregisteredRemovalLeftover(record.repoPath, record.worktreePath))
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(`[worktrees] could not list worktrees of ${record.repoPath}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { unregisteredFolderRefusal } from '../shared/worktree/removal'
|
||||
import { usesNativeWindowsRemoval } from './git/windows-worktree-removal'
|
||||
import type { LocalProjectWorktreeGitOptions } from './project-runtime-git-options'
|
||||
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 type { WorktreeRemovalRecord } from './worktree-removal-records'
|
||||
import { areWorktreePathsEqual } from './git/worktree-path-comparison'
|
||||
import { CLIENT_REMOVAL_HOME } from './worktree-removal-home-guard'
|
||||
import {
|
||||
failedWorktreeRemovals,
|
||||
persistWorktreeRemovalRecords,
|
||||
worktreeCheckoutExists
|
||||
} from './worktree-removal-table'
|
||||
import { failedWorktreeRemovals, worktreeCheckoutExists } from './worktree-removal-table'
|
||||
import {
|
||||
assertWorktreeDoesNotContainRegisteredWorktree,
|
||||
canSafelyRemoveOrphanedWorktreeDirectory
|
||||
@@ -35,21 +35,34 @@ export function differentCheckoutAtPathError(worktreePath: string): Error {
|
||||
)
|
||||
}
|
||||
|
||||
export async function isCheckoutRegistered(record: {
|
||||
export async function registeredRemovalCheckout(record: {
|
||||
repoPath: string
|
||||
worktreePath: string
|
||||
}): Promise<boolean> {
|
||||
return (await listWorktreesStrict(record.repoPath)).some((worktree) =>
|
||||
}): Promise<GitWorktreeInfo | undefined> {
|
||||
return (await listWorktreesStrict(record.repoPath)).find((worktree) =>
|
||||
areWorktreePathsEqual(worktree.path, record.worktreePath)
|
||||
)
|
||||
}
|
||||
|
||||
export function unregisteredFolderRefusal(worktreePath: string): Error {
|
||||
return new Error(
|
||||
`${worktreePath} is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace.`
|
||||
export async function isCheckoutRegistered(record: {
|
||||
repoPath: string
|
||||
worktreePath: string
|
||||
}): Promise<boolean> {
|
||||
return !!(await registeredRemovalCheckout(record))
|
||||
}
|
||||
|
||||
export function isRecordedCheckout(
|
||||
worktree: GitWorktreeInfo,
|
||||
record: WorktreeRemovalRecord
|
||||
): boolean {
|
||||
return (
|
||||
normalizeLocalBranchRef(worktree.branch) === record.branch &&
|
||||
(!record.head || worktree.head === record.head)
|
||||
)
|
||||
}
|
||||
|
||||
export { unregisteredFolderRefusal } from '../shared/worktree/removal'
|
||||
|
||||
/** Name nested worktrees before asking the user to remove an unregistered folder. */
|
||||
export async function assertUnregisteredCheckoutGone(
|
||||
worktreePath: string,
|
||||
@@ -66,16 +79,14 @@ export async function retryFailedRemovalUnlessRegistered(
|
||||
worktreeId: string,
|
||||
worktreePath: string,
|
||||
registeredWorktrees: readonly Pick<GitWorktreeInfo, 'path'>[],
|
||||
localOptions: LocalProjectWorktreeGitOptions,
|
||||
retry: () => Promise<RemoveWorktreeResult> | undefined
|
||||
): Promise<boolean> {
|
||||
if (registeredWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, worktreePath))) {
|
||||
if (failedWorktreeRemovals.delete(worktreeId)) {
|
||||
void persistWorktreeRemovalRecords()
|
||||
}
|
||||
return false
|
||||
}
|
||||
const failed = failedWorktreeRemovals.get(worktreeId)
|
||||
if (failed) {
|
||||
if (failed && !usesNativeWindowsRemoval(worktreePath, localOptions)) {
|
||||
await assertUnregisteredCheckoutGone(failed.worktreePath, registeredWorktrees)
|
||||
}
|
||||
return retry() !== undefined
|
||||
|
||||
@@ -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 { areWorktreePathsEqual, canonicalWorktreePath } from './git/worktree-path-comparison'
|
||||
import { isRecordedCheckout, isUnregisteredRemovalLeftover } from './worktree-removal-leftover'
|
||||
import type { WorktreeRemovalRecord } from './worktree-removal-records'
|
||||
import {
|
||||
failedWorktreeRemovals,
|
||||
@@ -21,16 +21,30 @@ export async function withUnregisteredRemovalCheckouts(
|
||||
repoId: string,
|
||||
gitWorktrees: GitWorktreeInfo[]
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
let droppedFailure = false
|
||||
const failures = new Map(
|
||||
[...failedWorktreeRemovals.values()]
|
||||
.filter((record) => record.repoId === repoId)
|
||||
.map((record) => [canonicalWorktreePath(record.worktreePath), record] as const)
|
||||
)
|
||||
gitWorktrees = gitWorktrees.map((row) => {
|
||||
const record = failures.get(canonicalWorktreePath(row.path))
|
||||
if (!record) {
|
||||
return row
|
||||
}
|
||||
if (isRecordedCheckout(row, record)) {
|
||||
return { ...row, removalError: record.failure?.message }
|
||||
}
|
||||
failedWorktreeRemovals.delete(record.worktreeId)
|
||||
droppedFailure = true
|
||||
return row
|
||||
})
|
||||
const unlisted = [...pendingWorktreeRemovals.values(), ...failedWorktreeRemovals.values()].filter(
|
||||
(record) =>
|
||||
record.repoId === repoId &&
|
||||
!gitWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, record.worktreePath))
|
||||
)
|
||||
if (unlisted.length === 0) {
|
||||
return gitWorktrees
|
||||
}
|
||||
const leftovers: GitWorktreeInfo[] = []
|
||||
let droppedFailure = false
|
||||
for (const record of unlisted) {
|
||||
const failed = failedWorktreeRemovals.get(record.worktreeId) === record
|
||||
if (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof'
|
||||
import type { WorktreeRemovalHomeAuthority } from './worktree-removal-home-guard'
|
||||
import type { GitWorktreeInfo } from '../shared/worktree/types'
|
||||
import {
|
||||
canSafelyRemoveOrphanedWorktreeDirectory,
|
||||
canSafelyRemoveOrphanedWorktreeDirectory as proveWithAccess,
|
||||
findRegisteredDeletableWorktree,
|
||||
getRegisteredDeletableWorktree,
|
||||
isDangerousWorktreeRemovalPath
|
||||
@@ -23,6 +25,11 @@ function missingPath(path: string): Error & { code: string } {
|
||||
return Object.assign(new Error(`missing ${path}`), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
const deniedPath = (operation: string, path: string) =>
|
||||
Object.assign(new Error(`EACCES: permission denied, ${operation} '${path}'`), {
|
||||
code: 'EACCES'
|
||||
})
|
||||
|
||||
function makeStatPath(filePaths: readonly string[], directoryPaths: readonly string[] = []) {
|
||||
const files = new Set(filePaths)
|
||||
const directories = new Set(directoryPaths)
|
||||
@@ -37,6 +44,25 @@ function makeStatPath(filePaths: readonly string[], directoryPaths: readonly str
|
||||
}
|
||||
}
|
||||
|
||||
function canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
candidate: string,
|
||||
owner: string,
|
||||
home: WorktreeRemovalHomeAuthority,
|
||||
statPath: StatPath,
|
||||
readPath: ReadPath
|
||||
): Promise<boolean> {
|
||||
// Ordinary fixture entries have identical following and nonfollowing kinds.
|
||||
return proveWithAccess(candidate, owner, home, {
|
||||
statPath,
|
||||
layoutStatPath: statPath,
|
||||
readPath,
|
||||
realPath: async (path) => {
|
||||
await statPath(path)
|
||||
return path
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeReadPath(entries: readonly (readonly [string, unknown])[]) {
|
||||
const files = new Map(entries)
|
||||
return async (path: string) => {
|
||||
@@ -114,55 +140,70 @@ describe('getRegisteredDeletableWorktree', () => {
|
||||
})
|
||||
|
||||
describe('canSafelyRemoveOrphanedWorktreeDirectory', () => {
|
||||
it('accepts a linked worktree .git file that points at this repo worktrees entry', async () => {
|
||||
it('accepts dotdot-prefixed lexical admin membership without canonical operations', async () => {
|
||||
const realPath = vi.fn(async () => {
|
||||
throw new Error('unexpected canonical operation')
|
||||
})
|
||||
await expect(
|
||||
canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
'/workspaces/orphan',
|
||||
'/repo',
|
||||
CLIENT_REMOVAL_HOME,
|
||||
makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']),
|
||||
makeReadPath([
|
||||
['/workspaces/orphan/.git', 'gitdir: /repo/.git/worktrees/orphan\n'],
|
||||
['/repo/.git/worktrees/orphan/gitdir', '/workspaces/orphan/.git\n']
|
||||
])
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('accepts repo worktree admin entries with dotdot-prefixed directory names', async () => {
|
||||
await expect(
|
||||
canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
'/workspaces/orphan',
|
||||
'/repo',
|
||||
CLIENT_REMOVAL_HOME,
|
||||
makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']),
|
||||
makeReadPath([
|
||||
proveWithAccess('/workspaces/orphan', '/repo', CLIENT_REMOVAL_HOME, {
|
||||
statPath: makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']),
|
||||
layoutStatPath: makeStatPath([], ['/repo/.git']),
|
||||
readPath: makeReadPath([
|
||||
['/workspaces/orphan/.git', 'gitdir: /repo/.git/worktrees/..orphan\n'],
|
||||
['/repo/.git/worktrees/..orphan/gitdir', '/workspaces/orphan/.git\n']
|
||||
])
|
||||
)
|
||||
]),
|
||||
realPath
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(realPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts remote filesystem provider readFile results for linked worktree .git files', async () => {
|
||||
await expect(
|
||||
canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
'/workspaces/orphan',
|
||||
'/repo',
|
||||
CLIENT_REMOVAL_HOME,
|
||||
makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']),
|
||||
makeReadPath([
|
||||
[
|
||||
'/workspaces/orphan/.git',
|
||||
{ isBinary: false, content: 'gitdir: /repo/.git/worktrees/orphan\n' }
|
||||
],
|
||||
[
|
||||
'/repo/.git/worktrees/orphan/gitdir',
|
||||
{ isBinary: false, content: '/workspaces/orphan/.git\n' }
|
||||
]
|
||||
])
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
it('requires definitive absence throughout owner fallback and missing-admin proof', async () => {
|
||||
const owner = '/repos/ENOENT'
|
||||
const candidate = '/workspaces/orphan'
|
||||
const admin = `${owner}/worktrees/orphan`
|
||||
const stat = makeStatPath([`${candidate}/.git`])
|
||||
const statPath = vi.fn(stat)
|
||||
const layoutStatPath = vi.fn(makeStatPath([]))
|
||||
const read = makeReadPath([
|
||||
[`${candidate}/.git`, { content: `gitdir: ${admin}\n`, isBinary: false }],
|
||||
[`${owner}/HEAD`, 'ref: refs/heads/main\n']
|
||||
])
|
||||
const readPath = vi.fn(read)
|
||||
const realPath = vi.fn(async () => {
|
||||
throw new Error('unexpected canonical operation')
|
||||
})
|
||||
const access = { statPath, layoutStatPath, readPath, realPath }
|
||||
const prove = () => proveWithAccess(candidate, owner, CLIENT_REMOVAL_HOME, access)
|
||||
await expect(prove()).resolves.toBe(true)
|
||||
const denied = deniedPath('stat', `${owner}/.git`)
|
||||
for (const error of [denied, new Error(`cannot inspect '${owner}/.git'`)]) {
|
||||
layoutStatPath.mockRejectedValueOnce(error)
|
||||
await expect(prove()).resolves.toBe(false)
|
||||
}
|
||||
// Each unknown replaces one independently missing observation in the successful proof.
|
||||
for (const unknownPath of [`${owner}/commondir`, `${admin}/gitdir`]) {
|
||||
readPath.mockImplementation(async (path) => {
|
||||
if (path === unknownPath) {
|
||||
throw deniedPath('open', path)
|
||||
}
|
||||
return read(path)
|
||||
})
|
||||
await expect(prove()).resolves.toBe(false)
|
||||
}
|
||||
readPath.mockImplementation(read)
|
||||
statPath.mockImplementation(async (path) => {
|
||||
if (path === admin) {
|
||||
throw deniedPath('stat', path)
|
||||
}
|
||||
return stat(path)
|
||||
})
|
||||
await expect(prove()).resolves.toBe(false)
|
||||
statPath.mockImplementation(stat)
|
||||
readPath.mockImplementation(async (path) =>
|
||||
path === `${admin}/gitdir` ? { content: `${candidate}/.git\n`, isBinary: false } : read(path)
|
||||
)
|
||||
await expect(prove()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('preserves forward-slash UNC roots when probing linked worktree .git files', async () => {
|
||||
@@ -245,16 +286,54 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a pruned admin entry when the candidate .git points under this repo worktrees dir', async () => {
|
||||
await expect(
|
||||
canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
'/workspaces/orphan',
|
||||
'/repo',
|
||||
CLIENT_REMOVAL_HOME,
|
||||
makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']),
|
||||
makeReadPath([['/workspaces/orphan/.git', 'gitdir: /repo/.git/worktrees/orphan\n']])
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
it('proves a pruned admin through an owner link without following candidate entries', async () => {
|
||||
const candidate = '/provider-only/workspaces/orphan'
|
||||
const owner = '/provider-only/repo'
|
||||
const physical = '/provider-only/metadata'
|
||||
const ordinaryStat = makeStatPath([`${candidate}/.git`])
|
||||
const statPath = vi.fn(async (path: string) => {
|
||||
if (path === `${owner}/.git`) {
|
||||
return { type: 'symlink' }
|
||||
}
|
||||
return ordinaryStat(path)
|
||||
})
|
||||
const layoutStatPath = vi.fn(async () => ({ type: 'directory' }))
|
||||
const realPath = vi.fn(async (path: string) => {
|
||||
if (path === `${owner}/.git` || path === physical) {
|
||||
return physical
|
||||
}
|
||||
throw missingPath(path)
|
||||
})
|
||||
const access = {
|
||||
statPath,
|
||||
layoutStatPath,
|
||||
realPath,
|
||||
readPath: makeReadPath([
|
||||
[
|
||||
`${candidate}/.git`,
|
||||
{ content: ` gitdir: ${physical}/worktrees/orphan\nignored`, isBinary: false }
|
||||
]
|
||||
])
|
||||
}
|
||||
const prove = () => proveWithAccess(candidate, owner, CLIENT_REMOVAL_HOME, access)
|
||||
await expect(prove()).resolves.toBe(true)
|
||||
expect(layoutStatPath).toHaveBeenCalledExactlyOnceWith(`${owner}/.git`)
|
||||
expect(statPath.mock.calls.map(([path]) => path)).toEqual([
|
||||
`${candidate}/.git`,
|
||||
`${physical}/worktrees/orphan`
|
||||
])
|
||||
expect(realPath.mock.calls.map(([path]) => path)).toEqual([
|
||||
`${physical}/worktrees`,
|
||||
`${owner}/.git/worktrees`,
|
||||
physical,
|
||||
`${owner}/.git`
|
||||
])
|
||||
realPath.mockResolvedValueOnce('')
|
||||
await expect(prove()).resolves.toBe(false)
|
||||
layoutStatPath.mockRejectedValueOnce(Object.assign(new Error('denied'), { code: 'EACCES' }))
|
||||
await expect(prove()).resolves.toBe(false)
|
||||
layoutStatPath.mockResolvedValueOnce({ type: 'other' })
|
||||
await expect(prove()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('rejects existing admin entries with a missing gitdir backlink', async () => {
|
||||
@@ -273,13 +352,14 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => {
|
||||
const readPath = vi.fn()
|
||||
|
||||
await expect(
|
||||
canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
'/workspaces/orphan',
|
||||
'/repo',
|
||||
CLIENT_REMOVAL_HOME,
|
||||
async () => ({ type: 'symlink' }),
|
||||
proveWithAccess('/workspaces/orphan', '/repo', CLIENT_REMOVAL_HOME, {
|
||||
statPath: async () => ({ type: 'symlink' }),
|
||||
layoutStatPath: async () => ({ type: 'directory' }),
|
||||
realPath: async () => {
|
||||
throw new Error('candidate link must refuse first')
|
||||
},
|
||||
readPath
|
||||
)
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(readPath).not.toHaveBeenCalled()
|
||||
@@ -315,21 +395,49 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => {
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a repo path that is itself a linked worktree', async () => {
|
||||
await expect(
|
||||
canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
'/workspaces/orphan',
|
||||
'/repos/main-linked',
|
||||
CLIENT_REMOVAL_HOME,
|
||||
makeStatPath(['/workspaces/orphan/.git', '/repos/main-linked/.git']),
|
||||
makeReadPath([
|
||||
['/workspaces/orphan/.git', 'gitdir: /common/.git/worktrees/orphan\n'],
|
||||
['/repos/main-linked/.git', 'gitdir: /common/.git/worktrees/main-linked\n'],
|
||||
['/common/.git/worktrees/main-linked/gitdir', '/repos/main-linked/.git\n'],
|
||||
['/common/.git/worktrees/orphan/gitdir', '/workspaces/orphan/.git\n']
|
||||
])
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
it('checks both owner memberships and the linked-owner alias backlink', async () => {
|
||||
const candidate = '/provider-only/orphan'
|
||||
const owner = '/provider-only/linked-alias'
|
||||
const common = '/provider-only/common-alias'
|
||||
const physical = '/provider-only/ENOENT'
|
||||
const ownerMarker = '/provider-only/linked/.git'
|
||||
const realPath = vi.fn(async (path: string) => {
|
||||
if (path === `${common}/worktrees` || path === `${physical}/worktrees`) {
|
||||
return `${physical}/worktrees`
|
||||
}
|
||||
if (path === `${owner}/.git` || path === ownerMarker) {
|
||||
return ownerMarker
|
||||
}
|
||||
throw missingPath(path)
|
||||
})
|
||||
const access = {
|
||||
statPath: makeStatPath([`${candidate}/.git`, `${owner}/.git`]),
|
||||
layoutStatPath: makeStatPath([`${owner}/.git`]),
|
||||
realPath,
|
||||
readPath: makeReadPath([
|
||||
[`${candidate}/.git`, `gitdir: ${physical}/worktrees/orphan\n`],
|
||||
[`${owner}/.git`, `gitdir: ${physical}/worktrees/owner\n`],
|
||||
[`${physical}/worktrees/owner/commondir`, common],
|
||||
[`${physical}/worktrees/owner/gitdir`, ownerMarker],
|
||||
[`${physical}/worktrees/orphan/gitdir`, `${candidate}/.git`]
|
||||
])
|
||||
}
|
||||
const prove = () => proveWithAccess(candidate, owner, CLIENT_REMOVAL_HOME, access)
|
||||
await expect(prove()).resolves.toBe(true)
|
||||
expect(realPath).toHaveBeenCalledTimes(6)
|
||||
access.readPath = makeReadPath([
|
||||
[`${candidate}/.git`, `gitdir: ${common}/worktrees/orphan\n`],
|
||||
[`${owner}/.git`, `gitdir: ${physical}/worktrees/owner\n`],
|
||||
[`${physical}/worktrees/owner/commondir`, common],
|
||||
[`${common}/worktrees/orphan/gitdir`, `${candidate}/.git`]
|
||||
])
|
||||
for (const error of [
|
||||
deniedPath('realpath', `${physical}/worktrees`),
|
||||
new Error(`owner unavailable, realpath '${physical}/worktrees'`)
|
||||
]) {
|
||||
realPath.mockRejectedValueOnce(error)
|
||||
await expect(prove()).resolves.toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects POSIX home directories even when host homedir has a different path shape', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lstat, readFile } from 'node:fs/promises'
|
||||
import { lstat, stat, readFile, realpath } from 'node:fs/promises'
|
||||
import type { WorktreeMeta } from '../shared/worktree/meta-types'
|
||||
import type { GitWorktreeInfo } from '../shared/worktree/types'
|
||||
import { NESTED_WORKTREE_REMOVAL_PREFIX } from '../shared/worktree/nested-removal'
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
} from './worktree-removal-home-guard'
|
||||
import {
|
||||
gitFileProvesOrphanedWorktreeDirectory,
|
||||
type ReadPath,
|
||||
type StatPath
|
||||
type WorktreeProofAccess
|
||||
} from './worktree-orphan-gitdir-proof'
|
||||
|
||||
const ORCA_CREATION_SOURCES = new Set<NonNullable<WorktreeMeta['orcaCreationSource']>>([
|
||||
@@ -130,8 +129,12 @@ export async function canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
worktreePath: string,
|
||||
repoPath: string,
|
||||
home: WorktreeRemovalHomeAuthority,
|
||||
statPath: StatPath = lstat,
|
||||
readPath: ReadPath = (path) => readFile(path, 'utf8')
|
||||
access: WorktreeProofAccess = {
|
||||
statPath: lstat,
|
||||
layoutStatPath: stat,
|
||||
readPath: (path) => readFile(path, 'utf8'),
|
||||
realPath: realpath
|
||||
}
|
||||
): Promise<boolean> {
|
||||
// A .git file can also belong to a dotfiles home; unknown host home must never authorize deletion.
|
||||
if (!isRemovalHomeAuthorityResolved(home)) {
|
||||
@@ -149,8 +152,7 @@ export async function canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
worktreePath,
|
||||
repoPath,
|
||||
pathOps,
|
||||
statPath,
|
||||
readPath
|
||||
access
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,16 @@ import { parseGitdirMarkerPayload } from './gitdir-marker-payload'
|
||||
|
||||
export type GitAdminReadOptions = GitMetadataPathOptions & { signal?: AbortSignal }
|
||||
|
||||
type GitLayoutStat = { isDirectory(): boolean; isFile(): boolean }
|
||||
export type GitCommonDirectoryReader = {
|
||||
statPath(path: string): Promise<GitLayoutStat>
|
||||
readPath(path: string): Promise<string | null>
|
||||
join(...paths: string[]): string
|
||||
resolvePath(base: string, pointer: string): string | null
|
||||
parseMarker(content: string): string | null
|
||||
isMissing(error: unknown): boolean
|
||||
}
|
||||
|
||||
export function isMissingGitAdminEntry(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
@@ -32,33 +42,40 @@ export async function readGitAdminFile(
|
||||
/** Read the owning host's Git layout without starting a subprocess. */
|
||||
export async function resolveGitCommonDirectory(
|
||||
repoPath: string,
|
||||
options: GitAdminReadOptions = {}
|
||||
options: GitAdminReadOptions = {},
|
||||
reader: GitCommonDirectoryReader = {
|
||||
statPath: (file) => waitForPromiseWithSignal(stat(file), options.signal),
|
||||
readPath: (file) => readGitAdminFile(file, options.signal),
|
||||
join: path.join,
|
||||
resolvePath: (base, pointer) => resolveGitMetadataPath(base, pointer, options),
|
||||
parseMarker: parseGitdirMarkerPayload,
|
||||
isMissing: isMissingGitAdminEntry
|
||||
}
|
||||
): Promise<string | null> {
|
||||
const dotGit = path.join(repoPath, '.git')
|
||||
const dotGit = reader.join(repoPath, '.git')
|
||||
let metadata: GitLayoutStat | undefined
|
||||
let gitDir: string | null = null
|
||||
try {
|
||||
const metadata = await waitForPromiseWithSignal(stat(dotGit), options.signal)
|
||||
if (metadata.isDirectory()) {
|
||||
gitDir = dotGit
|
||||
} else if (metadata.isFile()) {
|
||||
const marker = parseGitdirMarkerPayload(
|
||||
(await readGitAdminFile(dotGit, options.signal)) ?? ''
|
||||
)
|
||||
if (marker) {
|
||||
gitDir = resolveGitMetadataPath(repoPath, marker, options)
|
||||
}
|
||||
}
|
||||
metadata = await reader.statPath(dotGit)
|
||||
} catch (error) {
|
||||
if (!isMissingGitAdminEntry(error)) {
|
||||
if (!reader.isMissing(error)) {
|
||||
throw error
|
||||
}
|
||||
if ((await readGitAdminFile(path.join(repoPath, 'HEAD'), options.signal)) !== null) {
|
||||
if ((await reader.readPath(reader.join(repoPath, 'HEAD'))) !== null) {
|
||||
gitDir = repoPath
|
||||
}
|
||||
}
|
||||
if (metadata?.isDirectory()) {
|
||||
gitDir = dotGit
|
||||
} else if (metadata?.isFile()) {
|
||||
const marker = reader.parseMarker((await reader.readPath(dotGit)) ?? '')
|
||||
if (marker) {
|
||||
gitDir = reader.resolvePath(repoPath, marker)
|
||||
}
|
||||
}
|
||||
if (!gitDir) {
|
||||
return null
|
||||
}
|
||||
const commonDir = (await readGitAdminFile(path.join(gitDir, 'commondir'), options.signal))?.trim()
|
||||
return commonDir ? resolveGitMetadataPath(gitDir, commonDir, options) : gitDir
|
||||
const commonDir = (await reader.readPath(reader.join(gitDir, 'commondir')))?.trim()
|
||||
return commonDir ? reader.resolvePath(gitDir, commonDir) : gitDir
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ExecutionHostId } from '../execution-host'
|
||||
import type { GitWorktreeInfo, Worktree } from './types'
|
||||
|
||||
export function unregisteredFolderRefusal(worktreePath: string): Error {
|
||||
return new Error(
|
||||
`${worktreePath} is no longer a Git worktree, so its folder was left on disk. Delete the folder yourself to finish removing this workspace.`
|
||||
)
|
||||
}
|
||||
|
||||
export const LOCKED_WORKTREE_REMOVAL_PREFIX = 'Worktree is locked by Git.'
|
||||
|
||||
export const UNSTOPPED_PTY_REMOVAL_PREFIX = 'Failed to physically stop every PTY for worktree:'
|
||||
|
||||
Reference in New Issue
Block a user