AI skills in use in my daily
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

154 lines
6.9 KiB

<#
.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\<date>\ (never delete)
PurgeTrash delete _trash\<date>\ 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 }