function Remove-FileSystemItem { <# .SYNOPSIS Removes files or directories reliably and synchronously. .DESCRIPTION Removes files and directories, ensuring reliable and synchronous behavior across all supported platforms. The syntax is a subset of what Remove-Item supports; notably -Include is NOT supported; However, -Force and -Exclude are rudimentarily supported. As with Remove-Item, passing -Recurse is required to avoid a prompt when deleting a non-empty directory. IMPORTANT: * On Unix platforms, this function is merely a wrapper for Remove-Item, where the latter works reliably and synchronously, but on Windows a custom implementation must be used to ensure reliable and synchronous behavior. See https://github.com/PowerShell/PowerShell/issues/8211 * On Windows: * The *parent directory* of a directory being removed must be *writable* for the synchronous custom implementation to work. * The custom implementation is also applied when deleting directories on *network drives*. * If an indefinitely *locked* file or directory is encountered, removal is aborted. By contrast, files opened with FILE_SHARE_DELETE / [System.IO.FileShare]::Delete on Windows do NOT prevent removal, though they do live on under a temporary name in the parent directory until the last handle to them is closed. * Hidden files and files with the read-only attribute: * These are *quietly removed*; in other words: this function invariably behaves like `Remove-Item -Force`. * Note, however, that in order to target hidden files / directories as *input*, you must specify them as a *literal* path, because they won't be found via a wildcard expression. * The reliable custom implementation on Windows comes at the cost of decreased performance. .PARAMETER Path [string] This is the path to the item being deleted. Can be a file or folder. .PARAMETER LiteralPath [string] Used to identify items that may be hidden. Can be a file or folder. .PARAMETER Exclude [string] A pattern to match against using the naive powershell -match operator on the full item path .PARAMETER Force [switch] Avoid prompting, the function already tries to force-delete even without this flag. .PARAMETER Recurse [switch] Try to delete everything in a folder .PARAMETER SkipSymlinks [switch] Skip deleting symlinks if specified .EXAMPLE Remove-FileSystemItem C:\tmp -Recurse Synchronously removes directory C:\tmp and all its content. #> <#https://stackoverflow.com/questions/53553729/cannot-remove-item-the-directory-is-not-empty/53561052#53561052#> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidShouldContinueWithoutForce", '', Justification="It's okay to use force to pass continue", Scope = "Function")] [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'Path', PositionalBinding = $false)] param( [Parameter(ParameterSetName = 'Path', Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [string[]] $Path, [Parameter(ParameterSetName = 'Literalpath', ValueFromPipelineByPropertyName)] [Alias('PSPath')] [string[]] $LiteralPath, [string[]] $Exclude, [switch] $Recurse, [switch] $Force, [switch] $SkipSymlinks ) begin { $logLead = (Get-LogLeadName) if ($Force) { ## Force recursion when told to Force delete $Recurse = $true } $script:excludePresent = ![string]::IsNullOrWhiteSpace($Exclude) $script:excludedFilesFound = $false $script:excludeMatch = $Exclude # !! Workaround for https://github.com/PowerShell/PowerShell/issues/1759 if ($ErrorActionPreference -eq [System.Management.Automation.ActionPreference]::Ignore) { $ErrorActionPreference = 'Ignore'} $targetPath = '' $yesToAll = $noToAll = $false function trimTrailingPathSep([string] $itemPath) { if ($itemPath[-1] -in '\', '/') { # Trim the trailing separator, unless the path is a root path such as '/' or 'c:\' if ($itemPath.Length -gt 1 -and $itemPath -notmatch '^[^:\\/]+:.$') { $itemPath = $itemPath.Substring(0, $itemPath.Length - 1) } } $itemPath } function getTempPathOnSameVolume([string] $itemPath, [string] $tempDir) { if (-not $tempDir) { $tempDir = [IO.Path]::GetDirectoryName($itemPath) } [IO.Path]::Combine($tempDir, [IO.Path]::GetRandomFileName()) } function syncRemoveFile([string] $filePath, [string] $tempDir, [switch]$skipSymlink) { if ($script:excludePresent -and ($filePath -match $script:excludeMatch)) { $script:excludedFilesFound = $true Write-Verbose "$logLead : Skipping file due to exclude match : [$filePath] | [$script:excludeMatch]" return } $attribs = [IO.File]::GetAttributes($filePath) $isSymlink = ($attribs -band [System.IO.FileAttributes]::ReparsePoint) if ($isSymlink -and $skipSymlink) { # We don't mess with symlinked files here # We do, however, review that we touched them so we can not-delete folders $script:excludedFilesFound = $true } else { # Clear the ReadOnly attribute, if present. $isReadOnly = ($attribs -band [System.IO.FileAttributes]::ReadOnly) if ($isReadOnly) { [IO.File]::SetAttributes($filePath, $attribs -band -bnot [System.IO.FileAttributes]::ReadOnly) } if ($isSymlink) { # Symlinks delete much faster than files. [IO.File]::Delete($filePath) } else { # Faster disk IO if you move the files, then delete them, as file renames are _much_ faster to journaled file systems. $tempPath = getTempPathOnSameVolume -itemPath $filePath -tempDir $tempDir [IO.File]::Move($filePath, $tempPath) [IO.File]::Delete($tempPath) } } } function syncRemoveDir([string] $dirPath, [switch] $recursing, [switch]$skipSymlink) { if (-not $recursing) { $dirPathParent = [IO.Path]::GetDirectoryName($dirPath) } # If the path is a Symlink, remove it and return without recursing into the folder. # This is so we don't remove ex: c:\orb\shared\ but instead just the link. # Note: [IO.File]::*Attributes() is also used for *directories*; [IO.Directory] doesn't have attribute-related methods. ($attribs = [IO.File]::GetAttributes($dirPath)) $isSymlink = ($attribs -band [System.IO.FileAttributes]::ReparsePoint) $isReadOnly = ($attribs -band [System.IO.FileAttributes]::ReadOnly) if($isSymlink) { if($skipSymlink) { # We don't mess with symlinked files here # We do, however, review that we touched them so we can not-delete folders $script:excludedFilesFound = $true } else { # Clear the ReadOnly attribute, if present. if ($isReadOnly) { [IO.File]::SetAttributes($dirPath, $attribs -band -bnot [System.IO.FileAttributes]::ReadOnly) } # Remove the actual folder item [IO.Directory]::Delete($dirPath) } # leave the current subfunction stack return } # Clear the ReadOnly attribute, if present. if ($isReadOnly) { [IO.File]::SetAttributes($dirPath, $attribs -band -bnot [System.IO.FileAttributes]::ReadOnly) } # Remove all children synchronously. $isFirstChild = $true foreach ($item in [IO.directory]::EnumerateFileSystemEntries($dirPath)) { if (-not $recursing -and -not $Recurse -and $isFirstChild) { # If -Recurse wasn't specified, prompt for nonempty dirs. $isFirstChild = $false ## If you force the delete, don't even bother to prompt for confirmation if (!$Force) { # Note: If -Confirm was also passed, this prompt is displayed *in addition*, after the standard $PSCmdlet.ShouldProcess() prompt. # While Remove-Item also prompts twice in this scenario, it shows the has-children prompt *first*. $continuePrompt = "The item at '$dirPath' has children and the -Recurse switch was not specified. If you continue, all children will be removed with the item. Are you sure you want to continue?" $shouldContinue = $PSCmdlet.ShouldContinue($continuePrompt, 'Confirm', ([ref] $yesToAll), ([ref] $noToAll)) if (!$shouldContinue) { return } } } $itemPath = [IO.Path]::Combine($dirPath, $item) ([ref] $targetPath).Value = $itemPath if ([IO.Directory]::Exists($itemPath)) { syncremoveDir -dirPath $itemPath -recursing -skipSymlink:$skipSymlink } else { syncremoveFile -filePath $itemPath -tempDir $dirPathParent -skipSymlink:$skipSymlink } } if (!$script:excludedFilesFound) { Write-Verbose "$loglead : ExcludedFilesFound? [$script:excludedFilesFound]" Write-Verbose "$loglead : Removing folder [$dirPath]" # Finally, remove the directory itself synchronously if we didn't match a pattern on the input ([ref] $targetPath).Value = $dirPath $tempPath = (getTempPathOnSameVolume -itemPath $dirPath -tempDir $dirPathParent) [IO.Directory]::Move($dirPath, $tempPath) [IO.Directory]::Delete($tempPath) } } } process { $isLiteral = $PSCmdlet.ParameterSetName -eq 'LiteralPath' Write-Verbose "$loglead : Skip Symlimks? [$SkipSymlinks]" if ($env:OS -ne 'Windows_NT') { # Unix: simply pass through to Remove-Item, which on Unix works reliably and synchronously Remove-Item @PSBoundParameters } else { # Windows: use synchronous custom implementation foreach ($rawPath in ($Path, $LiteralPath)[$isLiteral]) { # Resolve the paths to full, filesystem-native paths. try { # !! Convert-Path does find hidden items via *literal* paths, but not via *wildcards* - and it has no -Force switch (yet) # !! See https://github.com/PowerShell/PowerShell/issues/6501 $resolvedPaths = if ($isLiteral) { Convert-Path -ErrorAction Stop -LiteralPath $rawPath } else { Convert-Path -ErrorAction Stop -path $rawPath} } catch { Write-Warning "$logLead : Could not delete file. More details follow." Write-Error $_ # relay error, but in the name of this function continue } try { $isDir = $false foreach ($resolvedPath in $resolvedPaths) { # -WhatIf and -Confirm support. if (-not $PSCmdlet.ShouldProcess($resolvedPath)) { continue } if ($isDir = [IO.Directory]::Exists($resolvedPath)) { # dir. # !! A trailing '\' or '/' causes directory removal to fail ("in use"), so we trim it first. syncRemoveDir -dirPath (trimTrailingPathSep $resolvedPath) -skipSymlink:$SkipSymlinks } elseif ([IO.File]::Exists($resolvedPath)) { # file syncRemoveFile -filePath $resolvedPath -skipSymlink:$SkipSymlinks } else { if ($Force) { Write-Warning "$logLead : Not a file-system path or no longer extant: $resolvedPath - was it deleted before now?" } else { throw "$logLead : Not a file-system path or no longer extant: $resolvedPath" } } } } catch { if ($isDir) { $exc = $_.Exception if ($exc.InnerException) { $exc = $exc.InnerException } if ($targetPath -eq $resolvedPath) { Write-Error "$logLead : Removal of directory '$resolvedPath' failed: $exc" } else { Write-Error "$logLead : Removal of directory '$resolvedPath' failed, because its content could not be (fully) removed: $targetPath`: $exc" } } else { Write-Warning "$logLead : Could not delete file. More details follow." Write-Error $_ # relay error, but in the name of this function } continue } } } } }