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
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 }
|
|
|