public/Remove-Bucket.ps1
|
function Remove-Bucket { <# .SYNOPSIS Removes one or more buckets and all their objects. .DESCRIPTION Deletes bucket directories and their contents. Supports exact names, multiple buckets, and wildcard patterns (including nested bucket paths like "projects/myapp"). Only removes directories containing bucket objects (or empty directories). Skips buckets with other file types. By default, only removes files in the target bucket and leaves nested bucket directories intact. Use -Recurse to remove the target and all nested buckets. Uses standard -Confirm/-WhatIf support (SupportsShouldProcess). -Force skips the confirmation prompt entirely. .PARAMETER Bucket Bucket name(s) or wildcard patterns to remove. Supports glob-style wildcards (*, ?). For nested buckets, use path notation like "projects/myapp". .PARAMETER Path Root directory for bucket storage. Default: $HOME/.buckets. .PARAMETER Recurse Remove the target bucket and all nested buckets beneath it. Without this flag, nested bucket directories are preserved. .PARAMETER Depth Maximum nesting depth when recursing. Default: unlimited. Depth 1 removes only the target bucket (same as no -Recurse). Depth 2 removes the target and immediate sub-buckets. Sub-buckets beyond this depth are preserved. .PARAMETER Force Skip confirmation prompt and remove immediately. .PARAMETER WhatIf Preview which buckets would be removed without actually deleting them. .PARAMETER Quiet Suppress progress output. .EXAMPLE Remove-Bucket -Bucket users .EXAMPLE Remove-Bucket -Bucket "projects/myapp" .EXAMPLE Remove-Bucket -Bucket "temp*" -Force .EXAMPLE Remove-Bucket -Bucket projects -Recurse #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $true, Position = 0, ValueFromRemainingArguments = $true)][string[]]$Bucket, [string]$Path, [switch]$Recurse, [int]$Depth = [int]::MaxValue, [switch]$Force, [switch]$Quiet ) if ([string]::IsNullOrWhiteSpace($Path)) { $Path = Get-DefaultPath } $Path = Resolve-SafePath -Path $Path function Find-MatchingBuckets { param([string]$Root, [string[]]$Patterns) function Scan-Dir { param([string]$Dir) $matched = @() if (-not [System.IO.Directory]::Exists($Dir)) { return $matched } $di = [System.IO.DirectoryInfo]::new($Dir) $relName = "" if ($Dir -ne $Root) { $relName = $Dir.Substring($Root.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar).Replace([System.IO.Path]::DirectorySeparatorChar, '/') } if ($Dir -ne $Root) { foreach ($pattern in $Patterns) { if ($pattern -match '[\*\?]') { if ($relName -like $pattern) { $matched += [PSCustomObject]@{ Name = $relName; Path = $Dir } break } } elseif ($pattern -eq "*" -or $relName -eq $pattern -or ($relName -like "$pattern*") -or ($relName -like "*/$pattern") -or ($relName -like "*/$pattern/*") -or ($relName -like "$pattern/*")) { $matched += [PSCustomObject]@{ Name = $relName; Path = $Dir } break } } } foreach ($subDir in $di.GetDirectories()) { if ($subDir.Name -eq ".buckets") { continue } $matched += Scan-Dir -Dir $subDir.FullName } $matched } if ([System.IO.Directory]::Exists($Root)) { Scan-Dir -Dir $Root } } $matched = Find-MatchingBuckets -Root $Path -Patterns $Bucket if ($matched.Count -eq 0) { Write-Warning "No buckets match the specified pattern(s)" return } $removable = @() $skippedBuckets = @() foreach ($m in $matched) { $resolvedRoot = Resolve-SafePath -Path $Path $resolvedBucket = Resolve-SafePath -Path $m.Path if (-not $resolvedBucket.StartsWith($resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) { $skippedBuckets += [PSCustomObject]@{ Name = $m.Name; Reason = "path resolves outside root" } continue } $di = [System.IO.DirectoryInfo]::new($m.Path) $allFiles = @($di.GetFiles()) $otherFiles = @($allFiles | Where-Object { $_.Extension -notin ".dat", ".json" }) if ($otherFiles.Count -gt 0) { $skippedBuckets += [PSCustomObject]@{ Name = $m.Name; Reason = "contains $($otherFiles.Count) non-bucket file(s): $($otherFiles.Name -join ', ')" } continue } $nestedBuckets = @() foreach ($subDir in $di.GetDirectories()) { if ($subDir.Name -eq ".buckets") { continue } if ($subDir.GetFiles("*.dat").Length -gt 0 -or $subDir.GetFiles("*.json").Length -gt 0) { $nestedBuckets += $subDir.Name } } $stats = Get-BucketStats -Bucket $m.Name -Path $Path $hasNested = $nestedBuckets.Count -gt 0 $removable += [PSCustomObject]@{ Name = $m.Name Objects = if ($stats) { $stats.ObjectCount } else { 0 } Size = if ($stats) { $stats.TotalSize } else { "0 KB" } Path = $m.Path HasNestedBuckets = $hasNested NestedBucketNames = $nestedBuckets } } if ($removable.Count -eq 0 -and $skippedBuckets.Count -eq 0) { return } # When -Recurse, deduplicate: only keep buckets that aren't subdirectories of other matched buckets if ($Recurse -and $removable.Count -gt 1) { $sorted = @($removable | Sort-Object { $_.Path.Length }) $topLevel = @() foreach ($r in $sorted) { $isChild = $false foreach ($existing in $topLevel) { if ($r.Path.StartsWith($existing.Path + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { $isChild = $true break } } if (-not $isChild) { $topLevel += $r } } $removable = $topLevel } if ($WhatIfPreference) { if ($removable.Count -gt 0) { Write-RemovalSummary -Title "What if: Remove the following bucket(s)" ` -Names $removable.Name -Counts $removable.Objects -Sizes $removable.Size -Nested $removable.NestedBucketNames if ($Recurse -and $Depth -lt [int]::MaxValue) { Write-Host " (recursion limited to depth $Depth)" -ForegroundColor $script:CSkip } } if ($skippedBuckets.Count -gt 0) { Write-Host " Skipped:" -ForegroundColor $script:CSkip foreach ($s in $skippedBuckets) { Write-Host " " -NoNewline Write-Host "$($s.Name)" -NoNewline -ForegroundColor $script:CPath Write-Host " — " -NoNewline -ForegroundColor $script:CMuted Write-Host "$($s.Reason)" -ForegroundColor $script:CError } } return } if ($removable.Count -eq 0 -and $skippedBuckets.Count -eq 0) { return } # Pre-confirmation summary (unless -Force or -Quiet) if (-not $Force -and -not $Quiet -and $removable.Count -gt 0) { $preserved = @() foreach ($r in $removable) { if ($r.HasNestedBuckets -and -not $Recurse) { $preserved += @($r.NestedBucketNames) } else { $preserved += @() } } Write-RemovalSummary -Title "Remove $($removable.Count) bucket(s)?" ` -Names $removable.Name -Counts $removable.Objects -Sizes $removable.Size -Nested $preserved if ($Recurse -and $Depth -lt [int]::MaxValue) { Write-Host " (recursion limited to depth $Depth)" -ForegroundColor $script:CSkip } } # Skipped buckets (always shown if any) if ($skippedBuckets.Count -gt 0 -and -not $Quiet) { foreach ($s in $skippedBuckets) { Write-Host " " -NoNewline -ForegroundColor $script:CMuted Write-Host "$($s.Name)" -NoNewline -ForegroundColor $script:CPath Write-Host " · " -NoNewline -ForegroundColor $script:CMuted Write-Host "$($s.Reason)" -ForegroundColor $script:CError } } $removedCount = 0 # Sort deepest paths first so children are deleted before parents $removable = @($removable | Sort-Object { $_.Path.Length } -Descending) foreach ($r in $removable) { if (-not [System.IO.Directory]::Exists($r.Path)) { continue } $finalDi = [System.IO.DirectoryInfo]::new($r.Path) $finalFiles = @($finalDi.GetFiles()) $finalOther = @($finalFiles | Where-Object { $_.Extension -notin ".dat", ".json" }) if ($finalOther.Count -gt 0) { Write-Warning "Bucket '$($r.Name)' now contains non-bucket files, aborting: $($finalOther.Name -join ', ')" continue } $target = "bucket '$($r.Name)' ($($r.Objects) object(s), $($r.Size))" $shouldRemove = $Force if (-not $Force) { $shouldRemove = $PSCmdlet.ShouldProcess($target, "Remove-Bucket") } if ($shouldRemove) { if ($r.HasNestedBuckets -and -not $Recurse) { $finalDirs = @($finalDi.GetDirectories()) foreach ($f in $finalFiles) { $f.Delete() } foreach ($d in $finalDirs) { $hasBucketFiles = $d.GetFiles("*.dat").Length -gt 0 -or $d.GetFiles("*.json").Length -gt 0 if (-not $hasBucketFiles -and $d.GetDirectories().Length -eq 0) { $d.Delete() } } $remainingDirs = @($finalDi.GetDirectories()) if ($remainingDirs.Count -eq 0 -and $finalDi.GetFiles().Length -eq 0) { $finalDi.Delete() } $cacheKeys = @($script:BucketPathCache.Keys) | Where-Object { $_ -like "*|$($r.Name)" } foreach ($ck in $cacheKeys) { $script:BucketPathCache.Remove($ck) } } elseif ($Recurse) { if ($Depth -eq [int]::MaxValue) { [System.IO.Directory]::Delete($r.Path, $true) } else { function Remove-WithDepthLimit { param([string]$Dir, [int]$CurrentDepth, [int]$MaxDepth) $di = [System.IO.DirectoryInfo]::new($Dir) foreach ($f in $di.GetFiles("*.dat")) { try { $f.Delete() } catch {} } foreach ($f in $di.GetFiles("*.json")) { try { $f.Delete() } catch {} } if ($CurrentDepth -lt $MaxDepth) { foreach ($sub in $di.GetDirectories()) { if ($sub.Name -eq '.buckets') { continue } Remove-WithDepthLimit -Dir $sub.FullName -CurrentDepth ($CurrentDepth + 1) -MaxDepth $MaxDepth } } $di.Refresh() $remainingFiles = @($di.GetFiles()) $remainingDirs = @($di.GetDirectories() | Where-Object { $_.Name -ne '.buckets' }) if ($remainingFiles.Count -eq 0 -and $remainingDirs.Count -eq 0) { try { $di.Delete() } catch {} } } Remove-WithDepthLimit -Dir $r.Path -CurrentDepth 0 -MaxDepth $Depth } $cacheKeys = @($script:BucketPathCache.Keys) | Where-Object { $_ -like "*|$($r.Name)*" } foreach ($ck in $cacheKeys) { $script:BucketPathCache.Remove($ck) } } else { $finalDirs = @($finalDi.GetDirectories()) if ($finalDirs.Count -gt 0) { Write-Warning "Bucket '$($r.Name)' contains non-bucket subdirectories, aborting" continue } [System.IO.Directory]::Delete($r.Path, $true) $cacheKeys = @($script:BucketPathCache.Keys) | Where-Object { $_ -like "*|$($r.Name)" } foreach ($ck in $cacheKeys) { $script:BucketPathCache.Remove($ck) } } $removedCount++ if (-not $Quiet) { Write-Host "$($r.Name)" -NoNewline -ForegroundColor $script:CPath Write-Host " · " -NoNewline -ForegroundColor $script:CMuted $objLabel = if ($r.Objects -eq 1) { "1 object" } else { "$($r.Objects) objects" } Write-Host $objLabel -NoNewline -ForegroundColor $script:CNum Write-Host " removed" -ForegroundColor $script:CMuted } } } if ($removedCount -gt 1 -and -not $Quiet) { Write-Host $removedCount -NoNewline -ForegroundColor $script:CNum Write-Host " buckets removed" -ForegroundColor $script:CMuted } } |