258 lines
13 KiB
PowerShell
258 lines
13 KiB
PowerShell
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\<files> 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |