Recover Windows worktree deletes from long paths (#6433)
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
parent
4a98b11e77
commit
d188e7d2f6
|
|
@ -18,6 +18,7 @@ import {
|
|||
mergeWorktree,
|
||||
parseWorktreeId,
|
||||
formatWorktreeRemovalError,
|
||||
isWindowsLongPathWorktreeRemovalError,
|
||||
isOrphanCompatiblePreflightError,
|
||||
isOrphanedWorktreeError,
|
||||
areWorktreePathsEqual
|
||||
|
|
@ -535,6 +536,32 @@ describe('isOrphanedWorktreeError', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('isWindowsLongPathWorktreeRemovalError', () => {
|
||||
it('matches Git for Windows long-path deletion failures on Windows', () => {
|
||||
const error = Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: 'error: failed to delete some/deep/file: Filename too long'
|
||||
})
|
||||
|
||||
expect(isWindowsLongPathWorktreeRemovalError(error, 'win32')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match long-path text off Windows', () => {
|
||||
const error = Object.assign(new Error('file name too long'), {
|
||||
stderr: 'Filename too long'
|
||||
})
|
||||
|
||||
expect(isWindowsLongPathWorktreeRemovalError(error, 'linux')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match unrelated Git removal failures on Windows', () => {
|
||||
const error = Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: 'fatal: contains modified or untracked files'
|
||||
})
|
||||
|
||||
expect(isWindowsLongPathWorktreeRemovalError(error, 'win32')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOrphanCompatiblePreflightError', () => {
|
||||
it('matches not-a-working-tree errors', () => {
|
||||
const error = Object.assign(new Error('git failed'), {
|
||||
|
|
|
|||
|
|
@ -281,6 +281,23 @@ export function isOrphanedWorktreeError(error: unknown): boolean {
|
|||
return /is not a working tree/.test(msg)
|
||||
}
|
||||
|
||||
export function isWindowsLongPathWorktreeRemovalError(
|
||||
error: unknown,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): boolean {
|
||||
if (platform !== 'win32' || typeof error !== 'object' || error === null) {
|
||||
return false
|
||||
}
|
||||
const errorWithDetails = error as { message?: unknown; stderr?: unknown; stdout?: unknown }
|
||||
const details = [errorWithDetails.stderr, errorWithDetails.stdout, errorWithDetails.message]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.join('\n')
|
||||
|
||||
// Why: Git for Windows has reported this failure through both stderr and the
|
||||
// thrown message, with wording that varies between "filename" and "path".
|
||||
return /(?:file ?name|path).{0,40}too long|too long.{0,40}(?:file ?name|path)/i.test(details)
|
||||
}
|
||||
|
||||
export function isOrphanCompatiblePreflightError(error: unknown): boolean {
|
||||
if (isOrphanedWorktreeError(error)) {
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -5440,6 +5440,139 @@ 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')
|
||||
mockKnownFeatureWorktree(worktreePath, repoPath)
|
||||
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}`
|
||||
|
||||
try {
|
||||
const result = await handlers['worktrees:remove'](null, {
|
||||
worktreeId,
|
||||
force: true
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
preservedBranch: { branchName: 'feature', head: 'feature' }
|
||||
})
|
||||
if (ORIGINAL_PLATFORM === 'win32') {
|
||||
await expect(lstat(worktreePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
}
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||||
cwd: '/workspace/repo'
|
||||
})
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||||
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')
|
||||
mockKnownFeatureWorktree()
|
||||
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({})
|
||||
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('keeps metadata when Windows long-path recovery deletes the directory but prune fails', async () => {
|
||||
setPlatform('win32')
|
||||
mockKnownFeatureWorktree()
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||||
removeWorktreeMock.mockRejectedValue(
|
||||
Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||||
})
|
||||
)
|
||||
gitExecFileAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error('git prune failed'), {
|
||||
stderr: 'fatal: unable to lock worktree admin dir'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
force: true
|
||||
})
|
||||
).rejects.toThrow('Git still has stale worktree registration')
|
||||
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('retries stale Git registration cleanup after prior local filesystem recovery', async () => {
|
||||
setPlatform('win32')
|
||||
const missingWorktreePath = 'C:\\workspace\\already-removed'
|
||||
const worktreeId = `repo-1::${missingWorktreePath}`
|
||||
mockKnownFeatureWorktree(missingWorktreePath)
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta())
|
||||
|
||||
const result = await handlers['worktrees:remove'](null, {
|
||||
worktreeId,
|
||||
force: true
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
preservedBranch: { branchName: 'feature', head: 'feature' }
|
||||
})
|
||||
expect(runHookMock).not.toHaveBeenCalled()
|
||||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||||
cwd: '/workspace/repo'
|
||||
})
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||||
})
|
||||
|
||||
it('refuses to delete the root workspace for folder-mode repos', async () => {
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-folder',
|
||||
|
|
|
|||
|
|
@ -125,6 +125,10 @@ import {
|
|||
removeLocalWorktreePath,
|
||||
toLocalWorktreeRuntimePath
|
||||
} from '../local-worktree-filesystem'
|
||||
import {
|
||||
pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval,
|
||||
recoverLocalWindowsLongPathWorktreeRemoval
|
||||
} from '../local-worktree-removal-recovery'
|
||||
|
||||
const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000
|
||||
const WORKTREE_LIST_ALL_CONCURRENCY = 8
|
||||
|
|
@ -1466,6 +1470,44 @@ export function registerWorktreeHandlers(
|
|||
const canonicalWorktreePath = registeredWorktree.path
|
||||
const deleteBranch = removedMeta?.preserveBranchOnDelete !== true
|
||||
|
||||
// Why: a prior forced Windows recovery can delete the directory but leave
|
||||
// Git's stale registration; retry by pruning instead of removing a missing path.
|
||||
if (
|
||||
!repo.connectionId &&
|
||||
args.force === true &&
|
||||
process.platform === 'win32' &&
|
||||
(isWindowsAbsolutePathLike(canonicalWorktreePath) ||
|
||||
!!localWorktreeGitOptions.wslDistro) &&
|
||||
removedMeta &&
|
||||
(await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions))
|
||||
) {
|
||||
const removalResult = await pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval({
|
||||
canonicalWorktreePath,
|
||||
repoPath: repo.path,
|
||||
localWorktreeGitOptions,
|
||||
registeredWorktree,
|
||||
deleteBranch
|
||||
})
|
||||
await cleanupUnusedWorktreePushTargetRemote(
|
||||
repo.path,
|
||||
args.worktreeId,
|
||||
removedPushTarget,
|
||||
store,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
rememberPreservedBranchCleanupTarget(
|
||||
args.worktreeId,
|
||||
removalResult,
|
||||
registeredWorktree.head,
|
||||
removedPushTarget
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return removalResult ?? {}
|
||||
}
|
||||
|
||||
let shouldTearDownPtys = true
|
||||
|
||||
// Run archive hook before removal so teardown scripts still see the worktree directory.
|
||||
|
|
@ -1596,8 +1638,22 @@ export function registerWorktreeHandlers(
|
|||
registeredWorktree.head
|
||||
)
|
||||
} catch (error) {
|
||||
// If git no longer tracks this worktree, clean up the directory and metadata
|
||||
if (isOrphanedWorktreeError(error)) {
|
||||
// Why: Git for Windows can fail long-path directory deletion after
|
||||
// Orca has already validated the target and explicit force delete.
|
||||
const recoveredRemovalResult = await recoverLocalWindowsLongPathWorktreeRemoval({
|
||||
error,
|
||||
force: args.force ?? false,
|
||||
canonicalWorktreePath,
|
||||
repoPath: repo.path,
|
||||
localWorktreeGitOptions,
|
||||
registeredWorktree,
|
||||
deleteBranch,
|
||||
closeWatcher: closeLocalWatcherForRemoval
|
||||
})
|
||||
if (recoveredRemovalResult) {
|
||||
removalResult = recoveredRemovalResult
|
||||
} else if (isOrphanedWorktreeError(error)) {
|
||||
// If git no longer tracks this worktree, clean up the directory and metadata
|
||||
console.warn(
|
||||
`[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up`
|
||||
)
|
||||
|
|
@ -1640,10 +1696,11 @@ export function registerWorktreeHandlers(
|
|||
invalidateAuthorizedRootsCache()
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return {}
|
||||
} else {
|
||||
throw new Error(
|
||||
formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)
|
||||
)
|
||||
}
|
||||
await cleanupUnusedWorktreePushTargetRemote(
|
||||
repo.path,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ vi.mock('node:fs/promises', () => ({
|
|||
rm: rmMock
|
||||
}))
|
||||
|
||||
import { getLocalWorktreePathAccess, removeLocalWorktreePath } from './local-worktree-filesystem'
|
||||
import {
|
||||
getLocalWorktreePathAccess,
|
||||
removeLocalWorktreePath,
|
||||
toHostRemovalPath
|
||||
} from './local-worktree-filesystem'
|
||||
|
||||
function completeExecFile(stdout = ''): void {
|
||||
execFileMock.mockImplementation((_file, _args, _options, callback) => {
|
||||
|
|
@ -59,10 +63,27 @@ describe('local worktree filesystem runtime access', () => {
|
|||
|
||||
expect(lstatMock).toHaveBeenCalledWith('C:\\repo\\.git')
|
||||
expect(readFileMock).toHaveBeenCalledWith('C:\\repo\\.git', 'utf8')
|
||||
expect(rmMock).toHaveBeenCalledWith('C:\\repo\\feature', { recursive: true, force: true })
|
||||
expect(rmMock).toHaveBeenCalledWith(toHostRemovalPath('C:\\repo\\feature'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses a Win32 long-path namespace for host removal on Windows', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const longPath = `C:\\repo\\${'nested\\'.repeat(40)}feature`
|
||||
|
||||
await removeLocalWorktreePath(longPath)
|
||||
|
||||
expect(toHostRemovalPath(longPath)).toBe(`\\\\?\\${longPath}`)
|
||||
expect(rmMock).toHaveBeenCalledWith(`\\\\?\\${longPath}`, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the selected WSL distro for stat, read, and removal on Windows', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
completeExecFile('file')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { execFile } from 'node:child_process'
|
||||
import { lstat, readFile, rm } from 'node:fs/promises'
|
||||
import { win32 } from 'node:path'
|
||||
import {
|
||||
buildWslLoginShellCommand,
|
||||
escapeWslShCommandForWindows,
|
||||
|
|
@ -8,7 +9,7 @@ import {
|
|||
import { toLinuxPath } from './wsl'
|
||||
import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof'
|
||||
|
||||
type LocalWorktreeFilesystemOptions = {
|
||||
export type LocalWorktreeFilesystemOptions = {
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +112,7 @@ export async function removeLocalWorktreePath(
|
|||
): Promise<void> {
|
||||
const distro = options.wslDistro?.trim()
|
||||
if (!shouldUseWslFilesystem(options) || !distro) {
|
||||
await rm(targetPath, { recursive: true, force: true })
|
||||
await rm(toHostRemovalPath(targetPath), { recursive: true, force: true })
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -119,3 +120,9 @@ export async function removeLocalWorktreePath(
|
|||
// Windows cannot delete safely. Run the deletion inside the selected distro.
|
||||
await runWslLoginShellCommand(distro, `rm -rf -- ${quotePosixShell(toLinuxPath(targetPath))}`)
|
||||
}
|
||||
|
||||
export function toHostRemovalPath(targetPath: string): string {
|
||||
// Why: Git for Windows can fail long recursive deletes even after Orca has
|
||||
// proven the worktree target; Node's host deletion should use Win32 long paths.
|
||||
return process.platform === 'win32' ? win32.toNamespacedPath(targetPath) : targetPath
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import type { GitWorktreeInfo, RemoveWorktreeResult } from '../shared/types'
|
||||
import {
|
||||
formatWorktreeRemovalError,
|
||||
isWindowsLongPathWorktreeRemovalError
|
||||
} from './ipc/worktree-logic'
|
||||
import { gitExecFileAsync } from './git/runner'
|
||||
import type { GitWorktreeExecOptions } from './git/worktree'
|
||||
import { removeLocalWorktreePath } from './local-worktree-filesystem'
|
||||
|
||||
type LocalWindowsLongPathRecoveryArgs = {
|
||||
error: unknown
|
||||
force: boolean
|
||||
canonicalWorktreePath: string
|
||||
repoPath: string
|
||||
localWorktreeGitOptions: GitWorktreeExecOptions
|
||||
registeredWorktree: Pick<GitWorktreeInfo, 'branch' | 'head'>
|
||||
deleteBranch: boolean
|
||||
closeWatcher: (worktreePath: string) => Promise<void>
|
||||
}
|
||||
|
||||
type StaleLocalWorktreeRegistrationArgs = Omit<
|
||||
LocalWindowsLongPathRecoveryArgs,
|
||||
'error' | 'force' | 'closeWatcher'
|
||||
>
|
||||
|
||||
function preservedBranchResult(
|
||||
registeredWorktree: Pick<GitWorktreeInfo, 'branch' | 'head'>,
|
||||
deleteBranch: boolean
|
||||
): RemoveWorktreeResult {
|
||||
if (!deleteBranch || !registeredWorktree.branch || !registeredWorktree.head) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
preservedBranch: {
|
||||
branchName: registeredWorktree.branch.replace(/^refs\/heads\//, ''),
|
||||
head: registeredWorktree.head
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneRequiredGitWorktreeRegistration(
|
||||
repoPath: string,
|
||||
localWorktreeGitOptions: GitWorktreeExecOptions,
|
||||
canonicalWorktreePath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await gitExecFileAsync(['worktree', 'prune'], {
|
||||
cwd: repoPath,
|
||||
...localWorktreeGitOptions
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${formatWorktreeRemovalError(
|
||||
error,
|
||||
canonicalWorktreePath,
|
||||
true
|
||||
)} The worktree directory was removed, but Git still has stale worktree registration. Retry deletion after resolving the Git prune error.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function recoverLocalWindowsLongPathWorktreeRemoval(
|
||||
args: LocalWindowsLongPathRecoveryArgs
|
||||
): Promise<RemoveWorktreeResult | undefined> {
|
||||
if (!args.force || !isWindowsLongPathWorktreeRemovalError(args.error)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Why: watcher shutdown is best-effort, but Git registration must be pruned
|
||||
// before callers clear Orca metadata or the branch remains locked.
|
||||
await args.closeWatcher(args.canonicalWorktreePath).catch(() => {})
|
||||
try {
|
||||
await removeLocalWorktreePath(args.canonicalWorktreePath, args.localWorktreeGitOptions)
|
||||
} catch (error) {
|
||||
throw new Error(formatWorktreeRemovalError(error, args.canonicalWorktreePath, true))
|
||||
}
|
||||
await pruneRequiredGitWorktreeRegistration(
|
||||
args.repoPath,
|
||||
args.localWorktreeGitOptions,
|
||||
args.canonicalWorktreePath
|
||||
)
|
||||
return preservedBranchResult(args.registeredWorktree, args.deleteBranch)
|
||||
}
|
||||
|
||||
export async function pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval(
|
||||
args: StaleLocalWorktreeRegistrationArgs
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
await pruneRequiredGitWorktreeRegistration(
|
||||
args.repoPath,
|
||||
args.localWorktreeGitOptions,
|
||||
args.canonicalWorktreePath
|
||||
)
|
||||
return preservedBranchResult(args.registeredWorktree, args.deleteBranch)
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ import { RpcDispatcher } from './rpc/dispatcher'
|
|||
import type { RpcRequest } from './rpc/core'
|
||||
import { TERMINAL_METHODS } from './rpc/methods/terminal'
|
||||
|
||||
const ORIGINAL_PLATFORM = process.platform
|
||||
const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
|
|
@ -20899,6 +20900,127 @@ describe('OrcaRuntimeService', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('recovers forced Windows runtime long-path removal and keeps skipped-hook warnings', async () => {
|
||||
setPlatform('win32')
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
await mkdir(TEST_WORKTREE_PATH, { recursive: true })
|
||||
await writeFile(join(TEST_WORKTREE_PATH, 'scratch.txt'), 'delete me')
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: ''
|
||||
})
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
archive: 'pnpm worktree:archive'
|
||||
}
|
||||
})
|
||||
vi.mocked(removeWorktree).mockRejectedValue(
|
||||
Object.assign(new Error('git worktree remove failed'), {
|
||||
stderr: 'error: failed to delete deep/file.txt: Filename too long'
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, 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)
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
await rm(TEST_WORKTREE_PATH, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps runtime metadata when long-path recovery deletes the directory but prune fails', async () => {
|
||||
setPlatform('win32')
|
||||
const removeWorktreeMeta = vi.fn()
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
removeWorktreeMeta
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockRejectedValue(
|
||||
Object.assign(new Error('git prune failed'), {
|
||||
stderr: 'fatal: unable to lock worktree admin dir'
|
||||
})
|
||||
)
|
||||
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'
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)).rejects.toThrow(
|
||||
'Git still has stale worktree registration'
|
||||
)
|
||||
expect(removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('retries stale runtime Git registration cleanup after prior filesystem recovery', async () => {
|
||||
setPlatform('win32')
|
||||
const missingWorktreePath = 'C:\\workspace\\already-removed'
|
||||
const worktreeId = `${TEST_REPO_ID}::${missingWorktreePath}`
|
||||
const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const registeredWorktrees = [
|
||||
{
|
||||
path: TEST_REPO_PATH,
|
||||
head: 'main',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: missingWorktreePath,
|
||||
head: 'abc',
|
||||
branch: 'refs/heads/feature/foo',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: ''
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue(registeredWorktrees)
|
||||
vi.mocked(listWorktreesStrict).mockResolvedValue(registeredWorktrees)
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
archive: 'pnpm worktree:archive'
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await runtime.removeManagedWorktree(worktreeId, true)
|
||||
|
||||
expect(result).toEqual({
|
||||
preservedBranch: { branchName: 'feature/foo', head: 'abc' }
|
||||
})
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
expect(gitSpy).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||||
cwd: TEST_REPO_PATH
|
||||
})
|
||||
expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes runtime worktree removal through the selected WSL project runtime', async () => {
|
||||
setPlatform('win32')
|
||||
const runtimeStore = {
|
||||
|
|
|
|||
|
|
@ -413,6 +413,10 @@ import {
|
|||
removeLocalWorktreePath,
|
||||
toLocalWorktreeRuntimePath
|
||||
} from '../local-worktree-filesystem'
|
||||
import {
|
||||
pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval,
|
||||
recoverLocalWindowsLongPathWorktreeRemoval
|
||||
} from '../local-worktree-removal-recovery'
|
||||
import {
|
||||
connect as connectLinear,
|
||||
disconnect as disconnectLinear,
|
||||
|
|
@ -14302,6 +14306,44 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
const canonicalWorktreePath = registeredWorktree.path
|
||||
const deleteBranch = removedMeta?.preserveBranchOnDelete !== true
|
||||
|
||||
// Why: a prior forced Windows recovery can delete the directory but leave
|
||||
// Git's stale registration; retry by pruning instead of removing a missing path.
|
||||
if (
|
||||
!repo.connectionId &&
|
||||
force === true &&
|
||||
process.platform === 'win32' &&
|
||||
(isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) &&
|
||||
removedMeta &&
|
||||
(await isRuntimeWorktreePathMissing(repo, canonicalWorktreePath, localWorktreeGitOptions))
|
||||
) {
|
||||
const removalResult = await pruneStaleLocalWorktreeRegistrationAfterFilesystemRemoval({
|
||||
canonicalWorktreePath,
|
||||
repoPath: repo.path,
|
||||
localWorktreeGitOptions,
|
||||
registeredWorktree,
|
||||
deleteBranch
|
||||
})
|
||||
await cleanupUnusedWorktreePushTargetRemote(
|
||||
repo.path,
|
||||
removalTarget.id,
|
||||
removedPushTarget,
|
||||
store,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
this.rememberPreservedBranchCleanupTarget(
|
||||
removalTarget.id,
|
||||
removalResult,
|
||||
registeredWorktree.head,
|
||||
removedPushTarget
|
||||
)
|
||||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||||
this.removeWorktreeMetadataAndHistory(store, removalTarget.id)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
invalidateAuthorizedRootsCache()
|
||||
this.notifyWorktreesChanged(repo.id)
|
||||
return removalResult ?? {}
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const rawRemovalResult = await (deleteBranch
|
||||
? provider!.removeWorktree(canonicalWorktreePath, force)
|
||||
|
|
@ -14412,7 +14454,24 @@ export class OrcaRuntimeService {
|
|||
registeredWorktree.head
|
||||
)
|
||||
} catch (error) {
|
||||
if (isOrphanedWorktreeError(error)) {
|
||||
// Why: Git for Windows can fail long-path directory deletion after
|
||||
// Orca has already validated the target and explicit force delete.
|
||||
const recoveredRemovalResult = await recoverLocalWindowsLongPathWorktreeRemoval({
|
||||
error,
|
||||
force,
|
||||
canonicalWorktreePath,
|
||||
repoPath: repo.path,
|
||||
localWorktreeGitOptions,
|
||||
registeredWorktree,
|
||||
deleteBranch,
|
||||
closeWatcher: (worktreePath) =>
|
||||
closeLocalWatcherForWorktreePath(worktreePath).catch((err) => {
|
||||
console.warn(`[filesystem-watcher] failed to close ${worktreePath}:`, err)
|
||||
})
|
||||
})
|
||||
if (recoveredRemovalResult) {
|
||||
removalResult = recoveredRemovalResult
|
||||
} else if (isOrphanedWorktreeError(error)) {
|
||||
const access = getLocalWorktreePathAccess(localWorktreeGitOptions)
|
||||
if (
|
||||
await canSafelyRemoveOrphanedWorktreeDirectory(
|
||||
|
|
@ -14457,8 +14516,9 @@ export class OrcaRuntimeService {
|
|||
return {
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
} else {
|
||||
throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force))
|
||||
}
|
||||
throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force))
|
||||
}
|
||||
|
||||
await cleanupUnusedWorktreePushTargetRemote(
|
||||
|
|
|
|||
Loading…
Reference in New Issue