From b3e805159ef2426e184b5e7a0fccb54c918ed89f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Sun, 19 Jul 2026 12:07:19 +0200 Subject: [PATCH] Clear TestDrive with .NET IO instead of per-item Remove-Item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear-TestDrive ran Remove-Item -Path -Force -Recurse for every new root item after each block. Remove-Item costs ~470us per item where IO.Directory/IO.File Delete costs ~85us, and -Path treats the path as a wildcard pattern, so a file named like 'file[1].txt' was never deleted and leaked across block teardowns. The .NET calls treat the path literally, delete reparse points without following them into their target (same as Remove-TestDrive already does for the whole drive since 2021), and anything that throws (read-only files on Windows, locked files) falls back to Remove-Item -LiteralPath -Force -ErrorAction Ignore, keeping the old semantics for those. Also replaced a Join-Path with plain string interpolation in New-RandomTempRegistry, registry provider paths always use backslash. 🤖 --- src/functions/TestDrive.ps1 | 23 ++++++++++++++++++++--- src/functions/TestRegistry.ps1 | 3 ++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/functions/TestDrive.ps1 b/src/functions/TestDrive.ps1 index c6570c2a7..74231a430 100644 --- a/src/functions/TestDrive.ps1 +++ b/src/functions/TestDrive.ps1 @@ -131,12 +131,29 @@ function Clear-TestDrive { [System.StringComparer]::OrdinalIgnoreCase) # Only delete "root" new items (those whose parent directory is not also a new item). - # Deleting with -Recurse removes all descendants in one call, avoiding redundant - # Remove-Item calls on already-deleted children. + # Deleting recursively removes all descendants in one call, avoiding redundant + # deletions of already-deleted children. foreach ($item in $newItemSet) { $parent = [IO.Path]::GetDirectoryName($item) if (-not $newItemSet.Contains($parent)) { - & $SafeCommands['Remove-Item'] -Path $item -Force -Recurse -ErrorAction Ignore + # .NET deletes are ~5x faster than Remove-Item and treat the path literally, so + # bracket or wildcard characters in file names no longer glob (Remove-Item -Path + # left a file named like 'file[1].txt' behind). Directory.Delete removes reparse + # points (symlinks, junctions) without following them into their target, same as + # Remove-TestDrive already does for the whole drive. Read-only or locked items throw + # and fall back to Remove-Item, which keeps the previous -Force -ErrorAction Ignore + # semantics for those. + try { + if ([IO.Directory]::Exists($item)) { + [IO.Directory]::Delete($item, $true) + } + else { + [IO.File]::Delete($item) + } + } + catch { + & $SafeCommands['Remove-Item'] -LiteralPath $item -Force -Recurse -ErrorAction Ignore + } } } } diff --git a/src/functions/TestRegistry.ps1 b/src/functions/TestRegistry.ps1 index d8386bfcd..4eb401746 100644 --- a/src/functions/TestRegistry.ps1 +++ b/src/functions/TestRegistry.ps1 @@ -95,7 +95,8 @@ function Invoke-TestRegistryWithRetry { function New-RandomTempRegistry { do { $tempPath = Get-TempRegistry - $Path = & $SafeCommands['Join-Path'] -Path $tempPath -ChildPath ([IO.Path]::GetRandomFileName().Substring(0, 4)) + # registry provider paths always use backslash; plain interpolation avoids a per-call cmdlet invocation + $Path = "$tempPath\$([IO.Path]::GetRandomFileName().Substring(0, 4))" # Test-Path is a read operation and can throw the same transient IOException as the # New-Item write below, so retry it the same way. $keyAlreadyExists = Invoke-TestRegistryWithRetry { & $SafeCommands['Test-Path'] -Path $Path -PathType Container }