bin/purge.ps1
|
# WinMole - Project Artifact Purge # Scans configured directories for build artifacts (node_modules, target, .venv, etc.) # and interactively selects which ones to permanently remove. param( [switch]$DryRun, [switch]$Paths ) $ErrorActionPreference = 'SilentlyContinue' . "$PSScriptRoot\..\lib\core.ps1" . "$PSScriptRoot\..\lib\ui.ps1" # ── Purge paths config ──────────────────────────────────────────────────────── $purgeCfgFile = Join-Path $script:WINMOLE_CONFIG 'purge_paths.txt' if ($Paths) { if (-not (Test-Path $script:WINMOLE_CONFIG)) { [void][System.IO.Directory]::CreateDirectory($script:WINMOLE_CONFIG) } if (-not (Test-Path $purgeCfgFile)) { @( "# WinMole Purge Paths - one directory per line" "# Default paths are used when this file is empty." "$env:USERPROFILE\Projects" "$env:USERPROFILE\dev" "$env:USERPROFILE\source" ) | Set-Content $purgeCfgFile } Write-SpectreHost " [deepskyblue1]Opening purge paths config:[/] $(Esc $purgeCfgFile)" Start-Process notepad $purgeCfgFile return } # ── Resolve scan roots ──────────────────────────────────────────────────────── $customPaths = @() if (Test-Path $purgeCfgFile) { $customPaths = Get-Content $purgeCfgFile -ErrorAction SilentlyContinue | Where-Object { $_ -and -not $_.StartsWith('#') } | ForEach-Object { $_.Trim() } | Where-Object { Test-Path $_ } } $scanRoots = if ($customPaths) { $customPaths } else { @( "$env:USERPROFILE\Projects", "$env:USERPROFILE\dev", "$env:USERPROFILE\source", "$env:USERPROFILE\repos", "$env:USERPROFILE\github", "$env:USERPROFILE\work", "$env:USERPROFILE\Documents\Projects", "$env:USERPROFILE\Desktop\Projects" ) | Where-Object { Test-Path $_ } } if (-not $scanRoots) { Write-Warn "No project directories found. Use 'mo purge --paths' to configure scan directories." return } # ── Artifact patterns ───────────────────────────────────────────────────────── $artifactPatterns = @( [pscustomobject]@{ Name = 'node_modules'; MaxDepth = 3 } [pscustomobject]@{ Name = 'target'; MaxDepth = 3 } [pscustomobject]@{ Name = '.gradle'; MaxDepth = 3 } [pscustomobject]@{ Name = 'build'; MaxDepth = 3 } [pscustomobject]@{ Name = 'dist'; MaxDepth = 3 } [pscustomobject]@{ Name = '.build'; MaxDepth = 3 } [pscustomobject]@{ Name = '__pycache__'; MaxDepth = 4 } [pscustomobject]@{ Name = '.venv'; MaxDepth = 3 } [pscustomobject]@{ Name = 'venv'; MaxDepth = 3 } [pscustomobject]@{ Name = '.env'; MaxDepth = 3 } [pscustomobject]@{ Name = '.next'; MaxDepth = 3 } [pscustomobject]@{ Name = '.nuxt'; MaxDepth = 3 } [pscustomobject]@{ Name = 'out'; MaxDepth = 3 } [pscustomobject]@{ Name = 'vendor'; MaxDepth = 3 } [pscustomobject]@{ Name = 'obj'; MaxDepth = 4 } [pscustomobject]@{ Name = 'bin'; MaxDepth = 4 } [pscustomobject]@{ Name = '.terraform'; MaxDepth = 3 } [pscustomobject]@{ Name = '.tox'; MaxDepth = 3 } [pscustomobject]@{ Name = 'coverage'; MaxDepth = 3 } [pscustomobject]@{ Name = '.pytest_cache'; MaxDepth = 4 } [pscustomobject]@{ Name = '.mypy_cache'; MaxDepth = 4 } [pscustomobject]@{ Name = 'Pods'; MaxDepth = 3 } [pscustomobject]@{ Name = '.svelte-kit'; MaxDepth = 3 } ) $patternSet = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase) foreach ($p in $artifactPatterns) { [void]$patternSet.Add($p.Name) } Write-Header -Title 'WinMole Purge' -Sub $(if ($DryRun) { '(dry run)' } else { '' }) Write-SpectreHost " [grey]Scanning for project artifacts... (this may take a moment)[/]" # ── Fast scan using .NET enumeration with runspaces ─────────────────────────── $found = [System.Collections.Concurrent.ConcurrentBag[object]]::new() $scanBlock = { param($root, $patNames, $maxD) $results = [System.Collections.Generic.List[object]]::new() function Scan-Dir { param([string]$dir, [int]$depth) if ($depth -gt $maxD) { return } try { $di = [System.IO.DirectoryInfo]::new($dir) foreach ($child in $di.EnumerateDirectories()) { if ($patNames.Contains($child.Name)) { $results.Add([pscustomobject]@{ Path = $child.FullName ArtifactName= $child.Name ProjectDir = $dir }) } else { Scan-Dir -dir $child.FullName -depth ($depth + 1) } } } catch {} } Scan-Dir -dir $root -depth 0 return $results } $pool = [runspacefactory]::CreateRunspacePool( 1, [Math]::Min($scanRoots.Count + 1, [Environment]::ProcessorCount)) $pool.Open() $handles = foreach ($root in $scanRoots) { $ps = [System.Management.Automation.PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($scanBlock).AddArgument($root).AddArgument($patternSet).AddArgument(4) [pscustomobject]@{ PS = $ps; Handle = $ps.BeginInvoke() } } $allFound = [System.Collections.Generic.List[object]]::new() foreach ($h in $handles) { try { $res = $h.PS.EndInvoke($h.Handle) if ($res) { foreach ($r in $res) { $allFound.Add($r) } } } catch {} $h.PS.Dispose() } $pool.Close(); $pool.Dispose() # ── Measure sizes in parallel ───────────────────────────────────────────────── $paths = @($allFound | ForEach-Object { $_.Path }) $sizes = if ($paths.Count -gt 0) { Get-DirectorySizes -Paths $paths } else { @{} } Write-Host "" if ($allFound.Count -eq 0) { Write-Info "No project artifacts found in: $($scanRoots -join ', ')" return } # ── Build menu items ────────────────────────────────────────────────────────── $cutoff = (Get-Date).AddDays(-7) $menuItems = foreach ($item in $allFound | Sort-Object { $sizes[$_.Path] } -Descending) { $size = if ($sizes.ContainsKey($item.Path)) { $sizes[$item.Path] } else { [long]0 } $projName= Split-Path -Leaf $item.ProjectDir if ($projName.Length -gt 22) { $projName = $projName.Substring(0, 19) + '...' } # Recent project check $isRecent = $false try { $lwt = [System.IO.Directory]::GetLastWriteTime($item.ProjectDir) $isRecent = ($lwt -gt $cutoff) } catch {} $tag = "$($item.ArtifactName)$(if ($isRecent) { ' | Recent' } else { '' })" [pscustomobject]@{ Label = $projName Size = $size Tag = $tag Selected = -not $isRecent Disabled = $false Path = $item.Path } } $menuItems = @($menuItems) $totalStr = Format-Bytes -Bytes ($menuItems | Measure-Object -Property Size -Sum).Sum $chosen = Show-ChecklistMenu -Title "Select Categories to Clean - $totalStr" -Items $menuItems if (-not $chosen) { Write-SpectreHost " [grey]Cancelled.[/]" Write-Host "" return } $toDelete = @($chosen | Where-Object { $_.Selected }) if ($toDelete.Count -eq 0) { Write-Info "Nothing selected." return } $deleteSize = ($toDelete | Measure-Object -Property Size -Sum).Sum $deleteMsg = "This will permanently delete $($toDelete.Count) artifact director$(if ($toDelete.Count -eq 1){'y'}else{'ies'}) ($(Format-Bytes $deleteSize))." Write-Host "" Write-SpectreHost " [bold red1]$(Esc $deleteMsg)[/]" if ($DryRun) { Write-Host "" Write-Info "Dry run — no files deleted." foreach ($d in $toDelete) { Write-SpectreHost " [grey]$(Esc $d.Path)[/]" } return } if (-not (Confirm-Action -Message 'Proceed with deletion?')) { Write-SpectreHost " [grey]Cancelled.[/]" Write-Host "" return } Write-Host "" $freed = [long]0 foreach ($d in $toDelete) { try { Remove-Item -LiteralPath $d.Path -Recurse -Force -ErrorAction Stop Write-Success -Message $d.Label -Right (Format-Bytes $d.Size) Write-OpLog "Purge: $($d.Path) ($(Format-Bytes $d.Size))" $freed += $d.Size } catch { Write-Warn "Could not delete: $($d.Path) — $_" } } Write-Host "" Write-Summary -Label 'Space freed:' -Value (Format-Bytes -Bytes $freed) |