Skip to content

Instantly share code, notes, and snippets.

@eru123
Created July 2, 2026 10:32
Show Gist options
  • Select an option

  • Save eru123/fbdbf0c4f83a18738d02e8167be62cef to your computer and use it in GitHub Desktop.

Select an option

Save eru123/fbdbf0c4f83a18738d02e8167be62cef to your computer and use it in GitHub Desktop.
Interactive disk cleanup tool for developers on Windows.
<#
.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 ""

dev-disk-cleanup.ps1

Interactive disk cleanup tool for developers on Windows. Reclaim space from regeneratable build artifacts, dependency folders, and caches without touching your source code.

Born from a real emergency: a dev machine hit 100% full on C:\ and we recovered ~100 GB in one session. This script packages that workflow into a reusable, safe, interactive tool.

Quick start

# Interactive cleanup — scans common dev roots, asks before each deletion
.\dev-disk-cleanup.ps1

# Preview only (deletes nothing)
.\dev-disk-cleanup.ps1 -DryRun

# Scan specific folders
.\dev-disk-cleanup.ps1 -ScanPaths C:\laragon\www, D:\repos

What it finds & offers to delete

Category Examples
NodeModules node_modules/
BuildOutputs dist/, build/, out/, .next/, .nuxt/, .svelte-kit/, .turbo/, coverage/, .vite/, .cache/
Vendor PHP vendor/
RustTarget target/ (cargo build output — often multi-GB)
Python __pycache__/, venv/, .venv/, .pytest_cache/, .mypy_cache/
DotNet bin/, obj/
Caches npm, pnpm, yarn, pip, bun, cargo, gradle, nuget, go-build, playwright stores
AppCaches VS Code, Chrome, Edge caches, CrashDumps, D3DSCache
TempFiles User Temp, Windows Temp, Windows Update downloads, Recycle Bin

Source code is never touched.git/, src/, and IDE config are explicitly protected.

Interactive prompt

For each item you get:

  [node_modules] 87.6 MB  C:\laragon\www\frozenhub\palawan-express\client\node_modules
    Delete? [Y]es / [N]o / [A]ll-in-category / [S]kip-rest / [Q]uit:
Key Action
Y / Enter Delete this item
N Skip this item
A Delete this item + all remaining in this category (no more prompts)
S Skip this item + all remaining in this category
Q Quit immediately

Parameters

Parameter Default Description
-ScanPaths common dev roots Directories to scan
-Categories All Which categories to scan (All,NodeModules,BuildOutputs,Vendor,RustTarget,Python,DotNet,Caches,AppCaches,TempFiles)
-DryRun off Preview — show what would be deleted, delete nothing
-NonInteractive off No prompts (use with -DryRun for reports, or alone to delete everything found)
-MaxDepth 5 Recursion depth for artifact search
-LogFile TEMP\dev-disk-cleanup.log Audit log path
-SkipPaths none Path fragments to always skip

Examples

# Only clean node_modules and Rust targets across two roots
.\dev-disk-cleanup.ps1 -ScanPaths C:\code,D:\repos -Categories NodeModules,RustTarget

# Deep scan a nested monorepo
.\dev-disk-cleanup.ps1 -ScanPaths C:\projects\mega -MaxDepth 8

# Protect a specific project from cleanup
.\dev-disk-cleanup.ps1 -SkipPaths "keep-this","reference-repo"

# Generate a report for a CI script (no deletion)
.\dev-disk-cleanup.ps1 -DryRun -NonInteractive -ScanPaths C:\build

Requirements

  • Windows PowerShell 5.1+ or PowerShell 7+
  • No admin rights needed for user-scope cleanup (Temp, AppData caches). Windows Temp / Windows Update cache may need elevation.

Safety

  • Nothing is deleted without confirmation (unless -NonInteractive).
  • -DryRun previews exact deletions and space freed.
  • Source directories (.git, src/) are never matched or recursed into.
  • Locked files are handled gracefully — partial deletions are reported.
  • Every action is logged to the audit log.

Contributing

The script is intentionally one self-contained file for easy gist/URL distribution. Please keep it that way.

  • Keep functions small and single-purpose.
  • Only target regeneratable build outputs, dependency folders, and caches — never source code.
  • Add new artifact patterns to the $ArtifactDefinitions table.
  • Add new cache stores to the $CacheDefinitions table.
  • Test with -DryRun before submitting.
  • Preserve the prompt contract: Y / N / A / S / Q.

License

MIT

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment