<# .SYNOPSIS Executes the safe half of Scratchpad cleanup. Dry-run unless -Execute. .DESCRIPTION Consumes scan.ps1's classification and performs only the reversible or provably-safe actions: AutoPurge delete .log/.err files and empty directories Quarantine MOVE stale files to _trash\\ (never delete) PurgeTrash delete _trash\\ folders past the grace period Restore move a quarantine batch back where it came from It deliberately does NOT act on the protected, promoteCandidates, supersession, or largeFiles buckets. Those require judgment and belong to the scratchpad-cleanup skill's report. Scratchpad is not a git repo, so quarantine is the only undo that exists. Every batch writes a manifest.json recording original paths. .EXAMPLE .\apply.ps1 # dry run, all safe modes .\apply.ps1 -Execute .\apply.ps1 -Mode Quarantine -Execute .\apply.ps1 -Mode Restore -RestoreDate 2026-09-14 -Execute #> [CmdletBinding()] param( [string] $Root = 'D:\Repos\Scratchpad', [ValidateSet('All', 'AutoPurge', 'Quarantine', 'PurgeTrash', 'Restore')] [string] $Mode = 'All', # Nothing is written unless this is present. Default is a dry run on purpose. [switch] $Execute, [int] $QuarantineAfterDays = 45, [int] $TrashGraceDays = 30, # Safety valve: refuse to quarantine more than this many files in one run. # Guards against a threshold change silently sweeping the whole folder. [int] $MaxQuarantinePerRun = 60, [string] $RestoreDate ) $ErrorActionPreference = 'Stop' $Root = (Resolve-Path -LiteralPath $Root).Path $trashRoot = Join-Path $Root '_trash' $stamp = (Get-Date).ToString('yyyy-MM-dd') $batchDir = Join-Path $trashRoot $stamp $scanScript = Join-Path $PSScriptRoot 'scan.ps1' if (-not (Test-Path -LiteralPath $scanScript)) { throw "scan.ps1 not found next to apply.ps1 ($scanScript)" } $actions = [System.Collections.ArrayList]::new() function Add-Action($kind, $from, $to, $note) { [void]$actions.Add([PSCustomObject]@{ kind = $kind; from = $from; to = $to; note = $note }) } # --- Restore is standalone; it does not need a scan --------------------------- if ($Mode -eq 'Restore') { if (-not $RestoreDate) { throw 'Restore requires -RestoreDate yyyy-MM-dd.' } $src = Join-Path $trashRoot $RestoreDate if (-not (Test-Path -LiteralPath $src)) { throw "No quarantine batch at $src" } $manifestPath = Join-Path $src 'manifest.json' if (-not (Test-Path -LiteralPath $manifestPath)) { throw "Batch has no manifest.json; refusing to guess original paths." } $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json foreach ($item in $manifest.items) { $from = Join-Path $src $item.rel $to = Join-Path $Root $item.rel if (-not (Test-Path -LiteralPath $from)) { Add-Action 'restore-skip' $from $to 'missing in batch'; continue } if (Test-Path -LiteralPath $to) { Add-Action 'restore-skip' $from $to 'destination already exists'; continue } Add-Action 'restore' $from $to '' if ($Execute) { $parent = Split-Path -Parent $to if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } Move-Item -LiteralPath $from -Destination $to } } $actions | Format-Table -AutoSize if (-not $Execute) { Write-Host 'DRY RUN - nothing moved. Re-run with -Execute.' -ForegroundColor Yellow } return } # --- everything else is scan-driven ------------------------------------------- $scanRaw = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scanScript -Root $Root -QuarantineAfterDays $QuarantineAfterDays -TrashGraceDays $TrashGraceDays $scan = ($scanRaw -join "`n") | ConvertFrom-Json if ($Mode -in 'All', 'AutoPurge') { foreach ($e in @($scan.buckets.autoPurgeFiles)) { $p = Join-Path $Root $e.rel Add-Action 'delete-file' $p '' $e.reason if ($Execute -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Force } } # Deepest-first so nested empties collapse correctly. foreach ($e in (@($scan.buckets.emptyDirs) | Sort-Object { ($_.rel -split '\\').Count } -Descending)) { $p = Join-Path $Root $e.rel Add-Action 'delete-dir' $p '' $e.reason if ($Execute -and (Test-Path -LiteralPath $p)) { # Re-check emptiness at execution time; scan data may be seconds stale. if (-not (Get-ChildItem -LiteralPath $p -Recurse -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) { Remove-Item -LiteralPath $p -Force } } } } if ($Mode -in 'All', 'Quarantine') { $q = @($scan.buckets.quarantine) if ($q.Count -gt $MaxQuarantinePerRun) { throw ("Refusing to quarantine {0} files in one run (cap {1}). This usually means QuarantineAfterDays was lowered. Review with scan.ps1 -ReportOnlyAges, then raise -MaxQuarantinePerRun deliberately." -f $q.Count, $MaxQuarantinePerRun) } $manifestItems = [System.Collections.ArrayList]::new() foreach ($e in $q) { $from = Join-Path $Root $e.rel $to = Join-Path $batchDir $e.rel Add-Action 'quarantine' $from $to $e.reason [void]$manifestItems.Add([PSCustomObject]@{ rel = $e.rel; ageDays = $e.ageDays; kb = $e.kb; reason = $e.reason }) if ($Execute -and (Test-Path -LiteralPath $from)) { $parent = Split-Path -Parent $to if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } Move-Item -LiteralPath $from -Destination $to -Force } } if ($Execute -and $manifestItems.Count -gt 0) { if (-not (Test-Path -LiteralPath $batchDir)) { New-Item -ItemType Directory -Path $batchDir -Force | Out-Null } [PSCustomObject]@{ batch = $stamp createdAt = (Get-Date).ToString('o') root = $Root purgeEligibleOn = (Get-Date).AddDays($TrashGraceDays).ToString('yyyy-MM-dd') items = @($manifestItems) } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $batchDir 'manifest.json') -Encoding UTF8 } } if ($Mode -in 'All', 'PurgeTrash') { foreach ($e in @($scan.buckets.trashExpired)) { $p = Join-Path $Root $e.rel Add-Action 'purge-trash' $p '' ("{0}d old, grace {1}d, {2} files" -f $e.ageDays, $TrashGraceDays, $e.files) if ($Execute -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Recurse -Force } } } if ($actions.Count -eq 0) { Write-Host 'Nothing to do.' -ForegroundColor Green } else { $actions | Group-Object kind | Sort-Object Name | ForEach-Object { '{0,-14} {1}' -f $_.Name, $_.Count } Write-Host '' $actions | Format-Table -AutoSize kind, from, note } if (-not $Execute) { Write-Host 'DRY RUN - nothing changed. Re-run with -Execute.' -ForegroundColor Yellow }