|
<# |
|
.SYNOPSIS |
|
Interactive disk cleanup tool for developers on Windows. |
|
|
|
.DESCRIPTION |
|
Scans your drives for regeneratable build artifacts, dependency folders, |
|
and caches (node_modules, vendor, dist, build, Rust target/, Python |
|
__pycache__/venv, .NET bin/obj, npm/pnpm/pip/gradle stores, browser & |
|
AppData caches, and more), then interactively asks whether to delete each |
|
one. Every item is sized before deletion and logged to a file so you have |
|
a full audit trail. |
|
|
|
Designed to be safe by default: nothing is deleted without confirmation, |
|
a -DryRun mode previews exactly what would be removed, and project source |
|
code (.git, src, etc.) is never touched. |
|
|
|
.PARAMETER ScanPaths |
|
One or more root directories to scan for build artifacts and dependency |
|
folders. Defaults to common dev roots (laragon/www, Projects, repos, etc.) |
|
that exist on the machine. |
|
|
|
.PARAMETER Categories |
|
Which categories to scan/offer for deletion. Default is 'All'. |
|
Valid values: All, NodeModules, BuildOutputs, Vendor, RustTarget, |
|
Python, DotNet, Caches, AppCaches, TempFiles. |
|
|
|
.PARAMETER DryRun |
|
Preview mode: shows everything that WOULD be deleted and the space it |
|
would free, but deletes nothing. |
|
|
|
.PARAMETER NonInteractive |
|
Do not prompt. Combine with -DryRun for a pure report, or use at your |
|
own risk to delete everything found without confirmation. |
|
|
|
.PARAMETER MaxDepth |
|
How deep to recurse when searching for artifact directories. |
|
Default 5. Increase for deeply nested monorepos. |
|
|
|
.PARAMETER LogFile |
|
Path to write the audit log of all deletions. Defaults to |
|
dev-disk-cleanup.log next to the script (or TEMP if read-only). |
|
|
|
.PARAMETER SkipPaths |
|
Array of path fragments to always skip (case-insensitive substring match). |
|
Useful to protect e.g. 'node_modules/.cache' or specific projects. |
|
|
|
.EXAMPLE |
|
.\dev-disk-cleanup.ps1 |
|
Interactive cleanup with all defaults — scans common dev roots. |
|
|
|
.EXAMPLE |
|
.\dev-disk-cleanup.ps1 -ScanPaths C:\laragon\www -Categories NodeModules,RustTarget |
|
Only scan laragon\www for node_modules and Rust target dirs. |
|
|
|
.EXAMPLE |
|
.\dev-disk-cleanup.ps1 -DryRun |
|
Preview exactly what would be cleaned and how much space it would free. |
|
|
|
.EXAMPLE |
|
.\dev-disk-cleanup.ps1 -ScanPaths C:\code,D:\repos -MaxDepth 6 |
|
Scan multiple roots with deeper recursion. |
|
|
|
.NOTES |
|
Author: Jericho (https://gist.github.com/) |
|
Created: 2026-07-02 |
|
License: MIT |
|
Requires: PowerShell 5.1+ (Windows PowerShell or PowerShell 7+) |
|
|
|
-------------------------------------------------------------------------- |
|
CONTRIBUTING |
|
-------------------------------------------------------------------------- |
|
This script is intentionally one self-contained file for easy gist/URL |
|
distribution. Please keep it that way. When contributing: |
|
|
|
* Keep functions small and single-purpose. |
|
* Never delete source code or .git directories — only build outputs, |
|
dependency folders, and caches that are regeneratable. |
|
* Add new artifact patterns to the $ArtifactDefinitions table below. |
|
* Test with -DryRun before submitting changes. |
|
* Preserve the interactive prompt contract: Y / N / A / S / Q. |
|
|
|
Submit PRs against the source gist. Bug reports welcome. |
|
#> |
|
|
|
[CmdletBinding()] |
|
param( |
|
[string[]]$ScanPaths, |
|
[ValidateSet('All','NodeModules','BuildOutputs','Vendor','RustTarget','Python','DotNet','Caches','AppCaches','TempFiles')] |
|
[string[]]$Categories = @('All'), |
|
|
|
[switch]$DryRun, |
|
[switch]$NonInteractive, |
|
|
|
[int]$MaxDepth = 5, |
|
[string]$LogFile, |
|
[string[]]$SkipPaths = @() |
|
) |
|
|
|
# ============================================================================ |
|
# CONFIGURATION |
|
# ============================================================================ |
|
|
|
# Artifact definitions: each describes a category of deletable directories. |
|
# Name - display label |
|
# Category - which -Categories token enables it |
|
# DirNames - directory names to match (case-insensitive) |
|
# SkipNested - if true, do not descend INTO matched dirs (e.g. node_modules |
|
# contains nested node_modules we don't want to double-count) |
|
# Safe - always regeneratable / safe to delete |
|
$ArtifactDefinitions = @( |
|
# --- Node.js / JS / TS --- |
|
@{ Name='node_modules'; Category='NodeModules'; DirNames=@('node_modules'); SkipNested=$true; Safe=$true } |
|
@{ Name='.next'; Category='BuildOutputs'; DirNames=@('.next'); SkipNested=$true; Safe=$true } |
|
@{ Name='.nuxt'; Category='BuildOutputs'; DirNames=@('.nuxt'); SkipNested=$true; Safe=$true } |
|
@{ Name='.svelte-kit'; Category='BuildOutputs'; DirNames=@('.svelte-kit'); SkipNested=$true; Safe=$true } |
|
@{ Name='.turbo'; Category='BuildOutputs'; DirNames=@('.turbo'); SkipNested=$true; Safe=$true } |
|
@{ Name='.parcel-cache'; Category='BuildOutputs'; DirNames=@('.parcel-cache');SkipNested=$true; Safe=$true } |
|
@{ Name='dist'; Category='BuildOutputs'; DirNames=@('dist'); SkipNested=$false; Safe=$true } |
|
@{ Name='build'; Category='BuildOutputs'; DirNames=@('build'); SkipNested=$false; Safe=$true } |
|
@{ Name='out'; Category='BuildOutputs'; DirNames=@('out'); SkipNested=$false; Safe=$true } |
|
@{ Name='coverage'; Category='BuildOutputs'; DirNames=@('coverage','.nyc_output'); SkipNested=$false; Safe=$true } |
|
@{ Name='.vite'; Category='BuildOutputs'; DirNames=@('.vite','.cache');SkipNested=$true; Safe=$true } |
|
|
|
# --- PHP --- |
|
@{ Name='vendor'; Category='Vendor'; DirNames=@('vendor'); SkipNested=$false; Safe=$true } |
|
|
|
# --- Rust --- |
|
@{ Name='target (Rust)'; Category='RustTarget'; DirNames=@('target'); SkipNested=$true; Safe=$true } |
|
|
|
# --- Python --- |
|
@{ Name='__pycache__'; Category='Python'; DirNames=@('__pycache__'); SkipNested=$false; Safe=$true } |
|
@{ Name='venv'; Category='Python'; DirNames=@('venv','.venv','env'); SkipNested=$true; Safe=$true } |
|
@{ Name='.pytest_cache'; Category='Python'; DirNames=@('.pytest_cache','.mypy_cache','.ruff_cache'); SkipNested=$true; Safe=$true } |
|
|
|
# --- .NET --- |
|
@{ Name='bin/obj'; Category='DotNet'; DirNames=@('bin','obj'); SkipNested=$false; Safe=$true } |
|
|
|
# --- Java / Gradle / Maven --- |
|
@{ Name='.gradle'; Category='BuildOutputs'; DirNames=@('.gradle'); SkipNested=$true; Safe=$true } |
|
) |
|
|
|
# Cache-store definitions: global package-manager & tool caches (not per-project). |
|
$CacheDefinitions = @( |
|
# Category=Caches |
|
@{ Name='npm cache'; Category='Caches'; Path="$env:LOCALAPPDATA\npm-cache" } |
|
@{ Name='npm cache (roaming)';Category='Caches'; Path="$env:APPDATA\npm-cache" } |
|
@{ Name='pnpm store'; Category='Caches'; Path="$env:LOCALAPPDATA\pnpm" } |
|
@{ Name='pnpm cache'; Category='Caches'; Path="$env:LOCALAPPDATA\pnpm-cache" } |
|
@{ Name='Yarn cache'; Category='Caches'; Path="$env:LOCALAPPDATA\Yarn" } |
|
@{ Name='pip cache'; Category='Caches'; Path="$env:LOCALAPPDATA\pip\Cache" } |
|
@{ Name='bun cache'; Category='Caches'; Path="$env:USERPROFILE\.bun" } |
|
@{ Name='cargo registry'; Category='Caches'; Path="$env:USERPROFILE\.cargo\registry" } |
|
@{ Name='gradle caches'; Category='Caches'; Path="$env:USERPROFILE\.gradle\caches" } |
|
@{ Name='NuGet packages'; Category='Caches'; Path="$env:USERPROFILE\.nuget\packages" } |
|
@{ Name='go-build cache'; Category='Caches'; Path="$env:LOCALAPPDATA\go-build" } |
|
@{ Name='Go directory'; Category='Caches'; Path="$env:USERPROFILE\go" } |
|
@{ Name='Playwright browsers';Category='Caches'; Path="$env:LOCALAPPDATA\ms-playwright" } |
|
@{ Name='ms-playwright-go'; Category='Caches'; Path="$env:LOCALAPPDATA\ms-playwright-go" } |
|
@{ Name='node-gyp'; Category='Caches'; Path="$env:LOCALAPPDATA\node-gyp" } |
|
|
|
# Category=AppCaches |
|
@{ Name='VS Code caches'; Category='AppCaches'; Path="$env:APPDATA\Code\Cache" } |
|
@{ Name='VS Code CachedData'; Category='AppCaches'; Path="$env:APPDATA\Code\CachedData" } |
|
@{ Name='VS Code Code Cache'; Category='AppCaches'; Path="$env:APPDATA\Code\Code Cache" } |
|
@{ Name='VS Code GPUCache'; Category='AppCaches'; Path="$env:APPDATA\Code\GPUCache" } |
|
@{ Name='Chrome Code Cache'; Category='AppCaches'; Path="$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Code Cache" } |
|
@{ Name='Chrome GPUCache'; Category='AppCaches'; Path="$env:LOCALAPPDATA\Google\Chrome\User Data\Default\GPUCache" } |
|
@{ Name='Edge Code Cache'; Category='AppCaches'; Path="$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Code Cache" } |
|
@{ Name='Edge GPUCache'; Category='AppCaches'; Path="$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\GPUCache" } |
|
@{ Name='CrashDumps'; Category='AppCaches'; Path="$env:LOCALAPPDATA\CrashDumps" } |
|
@{ Name='D3DSCache'; Category='AppCaches'; Path="$env:LOCALAPPDATA\D3DSCache" } |
|
@{ Name='SquirrelTemp'; Category='AppCaches'; Path="$env:LOCALAPPDATA\SquirrelTemp" } |
|
|
|
# Category=TempFiles |
|
@{ Name='User Temp'; Category='TempFiles'; Path="$env:LOCALAPPDATA\Temp" } |
|
@{ Name='Windows Temp'; Category='TempFiles'; Path="$env:WINDIR\Temp" } |
|
@{ Name='Windows Update DL'; Category='TempFiles'; Path="$env:WINDIR\SoftwareDistribution\Download" } |
|
) |
|
|
|
# Default scan roots (only those that exist are used) |
|
$DefaultScanRoots = @( |
|
"$env:USERPROFILE\ZCodeProject", |
|
'C:\laragon\www', |
|
'C:\Users\Jericho\ZCodeProject', |
|
'C:\code', |
|
'C:\projects', |
|
'C:\repos', |
|
'C:\dev', |
|
'C:\workspace', |
|
'C:\src', |
|
"$env:USERPROFILE\Projects", |
|
"$env:USERPROFILE\repos", |
|
"$env:USERPROFILE\code", |
|
"$env:USERPROFILE\dev", |
|
"$env:USERPROFILE\workspace", |
|
"$env:USERPROFILE\source" |
|
) |
|
|
|
# Directories we NEVER recurse into or delete (source control, IDE state) |
|
$ProtectedNames = @('.git', '.svn', '.hg', 'src', 'source', 'sources') |
|
|
|
# ============================================================================ |
|
# HELPERS |
|
# ============================================================================ |
|
|
|
function Write-Section($title) { |
|
$line = '=' * 72 |
|
Write-Host "" |
|
Write-Host $line -ForegroundColor DarkCyan |
|
Write-Host " $title" -ForegroundColor Cyan |
|
Write-Host $line -ForegroundColor DarkCyan |
|
} |
|
|
|
function Format-Size($bytes) { |
|
if ($null -eq $bytes -or $bytes -le 0) { return '0 B' } |
|
if ($bytes -ge 1GB) { return ('{0:N2} GB' -f ($bytes / 1GB)) } |
|
if ($bytes -ge 1MB) { return ('{0:N1} MB' -f ($bytes / 1MB)) } |
|
if ($bytes -ge 1KB) { return ('{0:N1} KB' -f ($bytes / 1KB)) } |
|
return ('{0} B' -f $bytes) |
|
} |
|
|
|
# Fast folder size using -File to avoid enumerating dir entries we don't need. |
|
function Get-FolderSize($path) { |
|
try { |
|
$sum = (Get-ChildItem -LiteralPath $path -Recurse -Force -File -ErrorAction SilentlyContinue | |
|
Measure-Object -Property Length -Sum).Sum |
|
if ($null -eq $sum) { return 0 } |
|
return [long]$sum |
|
} catch { return 0 } |
|
} |
|
|
|
function Write-Log { |
|
param([string]$Message, [string]$Level = 'INFO') |
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' |
|
$line = "[$ts] [$Level] $Message" |
|
if ($script:LogPath) { |
|
try { Add-Content -LiteralPath $script:LogPath -Value $line -ErrorAction SilentlyContinue } catch {} |
|
} |
|
} |
|
|
|
function Test-SkipPath($path) { |
|
foreach ($skip in $SkipPaths) { |
|
if ($path -like "*$skip*") { return $true } |
|
} |
|
return $false |
|
} |
|
|
|
# Recursive scanner: yields directories matching any artifact definition. |
|
function Find-Artifacts { |
|
param([string]$Root, [int]$Depth, [int]$CurrentDepth = 0, [hashtable]$Seen = @{}) |
|
|
|
if ($CurrentDepth -gt $Depth) { return } |
|
if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return } |
|
if (Test-SkipPath $Root) { return } |
|
|
|
$childDirs = @() |
|
try { |
|
$childDirs = Get-ChildItem -LiteralPath $Root -Directory -Force -ErrorAction SilentlyContinue |
|
} catch { return } |
|
|
|
foreach ($dir in $childDirs) { |
|
$name = $dir.Name |
|
$lname = $name.ToLower() |
|
|
|
# Skip protected dirs entirely (never recurse, never delete) |
|
if ($ProtectedNames -contains $lname) { continue } |
|
if (Test-SkipPath $dir.FullName) { continue } |
|
|
|
# Check against every artifact definition |
|
$matchedSkipNested = $false |
|
foreach ($def in $ArtifactDefinitions) { |
|
if ($def.DirNames -contains $lname) { |
|
# Only yield if its category is active |
|
if ($ActiveCategories -contains $def.Category -or $ActiveCategories -contains 'All') { |
|
if (-not $Seen.ContainsKey($dir.FullName)) { |
|
$Seen[$dir.FullName] = $true |
|
[pscustomobject]@{ |
|
Label = $def.Name |
|
Category = $def.Category |
|
Path = $dir.FullName |
|
Safe = $def.Safe |
|
} |
|
} |
|
} |
|
# If SkipNested, do NOT recurse into this dir |
|
if ($def.SkipNested) { $matchedSkipNested = $true; break } |
|
} |
|
} |
|
if ($matchedSkipNested) { continue } |
|
|
|
# Recurse |
|
Find-Artifacts -Root $dir.FullName -Depth $Depth -CurrentDepth ($CurrentDepth + 1) -Seen $Seen |
|
} |
|
} |
|
|
|
# ============================================================================ |
|
# INTERACTIVE PROMPT |
|
# ============================================================================ |
|
|
|
# Returns one of: 'yes','no','all','skip','quit' |
|
function Get-DeleteConfirmation { |
|
param([string]$Label, [string]$Path, [long]$SizeBytes) |
|
|
|
$sizeStr = Format-Size $SizeBytes |
|
Write-Host "" |
|
Write-Host " [$Label] " -NoNewline -ForegroundColor Yellow |
|
Write-Host "$sizeStr" -NoNewline -ForegroundColor Green |
|
Write-Host " $Path" -ForegroundColor Gray |
|
|
|
if ($DryRun) { return 'dryrun' } |
|
if ($NonInteractive) { return 'yes' } |
|
|
|
while ($true) { |
|
Write-Host " Delete? [Y]es / [N]o / [A]ll-in-category / [S]kip-rest / [Q]uit: " -NoNewline -ForegroundColor White |
|
$key = Read-Host |
|
$k = "$key".Trim().ToLower() |
|
switch ($k) { |
|
'y' { return 'yes' } |
|
'n' { return 'no' } |
|
'a' { return 'all' } |
|
's' { return 'skip' } |
|
'q' { return 'quit' } |
|
'' { return 'yes' } # Enter defaults to Yes |
|
default { |
|
Write-Host " Invalid choice. Y/N/A/S/Q" -ForegroundColor Red |
|
} |
|
} |
|
} |
|
} |
|
|
|
function Remove-Folder { |
|
param([string]$Path) |
|
try { |
|
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop |
|
return $true |
|
} catch { |
|
# Some files may be locked; retry skipping locked files |
|
try { |
|
Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue | |
|
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue |
|
# Remove what's left of the shell |
|
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue |
|
return -not (Test-Path -LiteralPath $Path) |
|
} catch { |
|
return $false |
|
} |
|
} |
|
} |
|
|
|
# ============================================================================ |
|
# MAIN |
|
# ============================================================================ |
|
|
|
$script:LogPath = if ($LogFile) { $LogFile } else { Join-Path $env:TEMP 'dev-disk-cleanup.log' } |
|
$ActiveCategories = $Categories |
|
|
|
if ($DryRun) { Write-Host "*** DRY RUN MODE - nothing will be deleted ***" -ForegroundColor Magenta } |
|
|
|
Write-Section 'Dev Disk Cleanup - Interactive' |
|
Write-Host " Log file : $script:LogPath" |
|
Write-Host " Max depth: $MaxDepth" |
|
Write-Host " Categories: $($Categories -join ', ')" |
|
Write-Host " Mode : $(if($NonInteractive){'Non-interactive'}else{'Interactive'})" |
|
Write-Log "=== Session started ===" |
|
Write-Log "DryRun=$DryRun NonInteractive=$NonInteractive Categories=$($Categories -join ',')" |
|
|
|
# Resolve scan paths |
|
if (-not $ScanPaths -or $ScanPaths.Count -eq 0) { |
|
$ScanPaths = $DefaultScanRoots | Where-Object { Test-Path $_ } |
|
} |
|
Write-Host " Scan roots: $($ScanPaths -join '; ')" -ForegroundColor Gray |
|
Write-Log "ScanPaths: $($ScanPaths -join '; ')" |
|
|
|
if ($ScanPaths.Count -eq 0) { |
|
Write-Host "`n No scan paths found. Pass -ScanPaths explicitly." -ForegroundColor Red |
|
exit 1 |
|
} |
|
|
|
# ---- Phase 1: scan project artifacts ---- |
|
Write-Section 'Scanning projects for build artifacts & dependencies' |
|
$artifacts = New-Object System.Collections.Generic.List[object] |
|
foreach ($root in $ScanPaths) { |
|
Write-Host " scanning: $root" -ForegroundColor DarkGray |
|
$seen = @{} |
|
Find-Artifacts -Root $root -Depth $MaxDepth -Seen $seen | ForEach-Object { |
|
$artifacts.Add($_) |
|
} |
|
} |
|
Write-Host " Found $($artifacts.Count) artifact directories." -ForegroundColor Cyan |
|
|
|
# Group by label for organized display |
|
$grouped = $artifacts | Group-Object Label | Sort-Object Name |
|
|
|
# Track stats |
|
$script:TotalFound = ($artifacts | Measure-Object).Count |
|
$script:TotalSizeFound = 0 |
|
$script:TotalDeleted = 0 |
|
$script:TotalSizeDeleted = 0 |
|
$script:TotalSkipped = 0 |
|
|
|
# Size everything (this is the slow part; show progress) |
|
Write-Section 'Sizing artifacts' |
|
for ($i = 0; $i -lt $artifacts.Count; $i++) { |
|
$a = $artifacts[$i] |
|
$size = Get-FolderSize $a.Path |
|
$a | Add-Member -NotePropertyName Size -NotePropertyValue $size -ErrorAction SilentlyContinue |
|
$script:TotalSizeFound += $size |
|
$pct = [int](($i + 1) / $artifacts.Count * 100) |
|
Write-Host ("`r [{0,3}%] {1}/{2} sized" -f $pct, ($i+1), $artifacts.Count) -NoNewline -ForegroundColor DarkGray |
|
} |
|
Write-Host "" |
|
Write-Host " Total recoverable from artifacts: $(Format-Size $script:TotalSizeFound)" -ForegroundColor Green |
|
|
|
# ---- Phase 2: interactively delete artifacts ---- |
|
Write-Section 'Review & delete (artifacts)' |
|
|
|
foreach ($grp in $grouped) { |
|
$label = $grp.Name |
|
$items = $grp.Group | Sort-Object Size -Descending |
|
$deleteAllInCategory = $false |
|
$skipRest = $false |
|
|
|
Write-Host "" |
|
Write-Host " -- $label ($($items.Count) dirs, $(Format-Size (($items | Measure-Object Size -Sum).Sum))) --" -ForegroundColor Cyan |
|
|
|
foreach ($item in $items) { |
|
if ($skipRest) { |
|
$script:TotalSkipped++ |
|
Write-Log "Skipped (category skip): $($item.Path)" |
|
continue |
|
} |
|
|
|
# Decide what to do with this item |
|
if ($deleteAllInCategory) { |
|
$action = 'all' # auto-delete without prompting |
|
} else { |
|
$action = Get-DeleteConfirmation -Label $label -Path $item.Path -SizeBytes $item.Size |
|
} |
|
|
|
switch ($action) { |
|
'quit' { Write-Host " Quitting." -ForegroundColor Magenta } |
|
'no' { $script:TotalSkipped++; Write-Log "Skipped (user): $($item.Path)" } |
|
'dryrun' { Write-Log "DryRun would-delete: $($item.Path) ($(Format-Size $item.Size))" } |
|
'skip' { $skipRest = $true; $script:TotalSkipped++; Write-Log "Skipped (skip-rest): $($item.Path)" } |
|
'all' { $deleteAllInCategory = $true; $action = 'yes' } # set flag, then fall through |
|
'yes' { } |
|
} |
|
|
|
if ($action -eq 'quit') { break } |
|
|
|
# Delete (covers both 'yes' and 'all' which set action='yes') |
|
if ($action -eq 'yes' -or $action -eq 'dryrun') { |
|
if ($DryRun -or $action -eq 'dryrun') { |
|
Write-Host " -> DRY RUN: would delete $(Format-Size $item.Size)" -ForegroundColor DarkYellow |
|
Write-Log "DryRun would-delete: $($item.Path) ($(Format-Size $item.Size))" |
|
$script:TotalDeleted++ |
|
$script:TotalSizeDeleted += $item.Size |
|
} else { |
|
Write-Host " deleting $(Format-Size $item.Size)..." -NoNewline -ForegroundColor DarkGray |
|
$ok = Remove-Folder -Path $item.Path |
|
if ($ok) { |
|
Write-Host " done" -ForegroundColor Green |
|
$script:TotalDeleted++ |
|
$script:TotalSizeDeleted += $item.Size |
|
Write-Log "Deleted: $($item.Path) ($(Format-Size $item.Size))" |
|
} else { |
|
$remain = 0 |
|
if (Test-Path $item.Path) { $remain = Get-FolderSize $item.Path } |
|
$freed = $item.Size - $remain |
|
$script:TotalSizeDeleted += $freed |
|
Write-Host " PARTIAL (locked files, freed $(Format-Size $freed))" -ForegroundColor Yellow |
|
Write-Log "Partial delete: $($item.Path) (freed $(Format-Size $freed), $(Format-Size $remain) locked)" |
|
} |
|
} |
|
} |
|
} |
|
if ($action -eq 'quit') { break } |
|
} |
|
|
|
# ---- Phase 3: caches ---- |
|
if ($ActiveCategories -contains 'Caches' -or $ActiveCategories -contains 'AppCaches' -or $ActiveCategories -contains 'TempFiles' -or $ActiveCategories -contains 'All') { |
|
Write-Section 'Cache stores & temp files' |
|
$caches = $CacheDefinitions | Where-Object { |
|
($ActiveCategories -contains $_.Category -or $ActiveCategories -contains 'All') -and (Test-Path $_.Path) |
|
} |
|
|
|
$skipCaches = $false |
|
foreach ($c in $caches) { |
|
if ($skipCaches) { break } |
|
$size = Get-FolderSize $c.Path |
|
if ($size -eq 0) { |
|
Write-Host " [$($c.Name)] empty - skip" -ForegroundColor DarkGray |
|
continue |
|
} |
|
|
|
$action = Get-DeleteConfirmation -Label $c.Name -Path $c.Path -SizeBytes $size |
|
switch ($action) { |
|
'quit' { Write-Host " Quitting." -ForegroundColor Magenta } |
|
'no' { Write-Log "Skipped cache (user): $($c.Path)" } |
|
'dryrun' { |
|
Write-Host " -> DRY RUN: would delete $(Format-Size $size)" -ForegroundColor DarkYellow |
|
Write-Log "DryRun would-delete cache: $($c.Path) ($(Format-Size $size))" |
|
$script:TotalSizeDeleted += $size |
|
} |
|
'skip' { $skipCaches = $true; Write-Log "Skipped caches (skip-rest)" } |
|
'yes' { |
|
Write-Host " deleting $(Format-Size $size)..." -NoNewline -ForegroundColor DarkGray |
|
# For cache dirs, delete CONTENTS not the dir itself (some apps expect the folder to exist) |
|
Get-ChildItem -LiteralPath $c.Path -Force -ErrorAction SilentlyContinue | ForEach-Object { |
|
Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue |
|
} |
|
Write-Host " done" -ForegroundColor Green |
|
$script:TotalSizeDeleted += $size |
|
Write-Log "Cleaned cache: $($c.Path) ($(Format-Size $size))" |
|
} |
|
} |
|
if ($action -eq 'quit') { break } |
|
} |
|
|
|
# Recycle Bin (skip prompt in non-interactive/dry-run unless user is present) |
|
if (-not $skipCaches -and -not $NonInteractive) { |
|
Write-Host "" |
|
Write-Host " Empty Recycle Bin?" -NoNewline -ForegroundColor White |
|
$rb = Read-Host " [Y/n]" |
|
if ("$rb".Trim().ToLower() -ne 'n') { |
|
if ($DryRun) { |
|
Write-Host " -> DRY RUN: would empty Recycle Bin" -ForegroundColor DarkYellow |
|
} else { |
|
Clear-RecycleBin -Force -ErrorAction SilentlyContinue |
|
Write-Host " Recycle Bin emptied." -ForegroundColor Green |
|
Write-Log "Emptied Recycle Bin" |
|
} |
|
} |
|
} elseif (-not $skipCaches -and $NonInteractive -and -not $DryRun) { |
|
Clear-RecycleBin -Force -ErrorAction SilentlyContinue |
|
Write-Log "Emptied Recycle Bin (non-interactive)" |
|
} |
|
} |
|
|
|
# ---- Summary ---- |
|
Write-Section 'Summary' |
|
Write-Host " Directories found : $script:TotalFound" |
|
Write-Host " Directories deleted : $script:TotalDeleted" -ForegroundColor Green |
|
Write-Host " Directories skipped : $script:TotalSkipped" -ForegroundColor DarkGray |
|
Write-Host " Total size found : $(Format-Size $script:TotalSizeFound)" -ForegroundColor Cyan |
|
Write-Host " Total size freed : $(Format-Size $script:TotalSizeDeleted)" -ForegroundColor Green |
|
Write-Host "" |
|
Write-Host " Full log: $script:LogPath" -ForegroundColor DarkGray |
|
Write-Log "=== Session complete: deleted=$script:TotalDeleted freed=$(Format-Size $script:TotalSizeDeleted) ===" |
|
|
|
if ($DryRun) { |
|
Write-Host "" |
|
Write-Host " (Dry run - re-run without -DryRun to actually delete)" -ForegroundColor Magenta |
|
} |
|
Write-Host "" |