6 changed files with 553 additions and 31 deletions
@ -0,0 +1,140 @@ |
|||
--- |
|||
name: "scratchpad-cleanup" |
|||
description: "Use when tidying, auditing, or freeing space in the Scratchpad working folder at D:\\Repos\\Scratchpad - the weekly sweep or an ad-hoc pass. Also use when reviewing or restoring files quarantined under _trash, or deciding whether a scratch artifact should be promoted into the Obsidian vault. Triggers: /scratchpad-cleanup, clean up scratchpad, tidy scratchpad, scratchpad is a mess, what can I delete in scratchpad, restore from _trash." |
|||
--- |
|||
|
|||
## Scratchpad Cleanup |
|||
|
|||
Keeps `D:\Repos\Scratchpad` from silting up, without ever destroying something |
|||
that mattered. |
|||
|
|||
**Core principle: Scratchpad is NOT a git repo.** There is no history and no |
|||
undo. Every irreversible action is therefore either provably safe or routed |
|||
through a `_trash\<date>\` quarantine with a grace period. The quarantine *is* |
|||
the undo. |
|||
|
|||
### When to Use |
|||
|
|||
- The weekly automated sweep. |
|||
- Ad-hoc: "scratchpad is a mess", "what can I delete", "what's stale in there". |
|||
- Reviewing what got quarantined, or restoring something binned by mistake. |
|||
- Deciding whether a scratch artifact is actually a durable finding that belongs |
|||
in the vault or a repo. |
|||
|
|||
### Division of labour |
|||
|
|||
The mechanical decisions live in scripts so they are reproducible and identical |
|||
every run. The skill only makes the calls a script *shouldn't*. |
|||
|
|||
| Script | Does | Writes? | |
|||
|---|---|---| |
|||
| `scan.ps1` | Classifies every file into buckets, emits JSON | No - read-only | |
|||
| `apply.ps1` | Executes the safe actions | Only with `-Execute` | |
|||
|
|||
```pwsh |
|||
# classify (read-only); this is always step 1 |
|||
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\scan.ps1" | ConvertFrom-Json |
|||
|
|||
# preview actions, then commit them |
|||
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\apply.ps1" |
|||
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\apply.ps1" -Execute |
|||
|
|||
# undo a batch |
|||
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\apply.ps1" -Mode Restore -RestoreDate 2026-09-14 -Execute |
|||
|
|||
# before ever changing the age threshold, look at the cliff |
|||
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\scan.ps1" -ReportOnlyAges |
|||
``` |
|||
|
|||
**Always invoke the scripts with `powershell -File` and an absolute path.** They |
|||
are saved UTF-8 *with BOM* deliberately: Windows PowerShell 5.1 reads a BOM-less |
|||
file as Windows-1252, which corrupts any non-ASCII byte and silently changes |
|||
control flow. Keep both scripts ASCII-only; do not paste em dashes or smart |
|||
quotes into them. |
|||
|
|||
### Buckets, and who owns each |
|||
|
|||
| Bucket | Owner | Action | |
|||
|---|---|---| |
|||
| `autoPurgeFiles` | script | delete - `.log` / `.err` | |
|||
| `emptyDirs` | script | delete | |
|||
| `quarantine` | script | move to `_trash\<date>\` | |
|||
| `trashExpired` | script | delete batches past grace | |
|||
| `protected` | nobody | never touched | |
|||
| `largeFiles` | **you** | report only | |
|||
| `supersession` | **you** | report only | |
|||
| `promoteCandidates` | **you** | report only | |
|||
|
|||
### The judgment calls |
|||
|
|||
**1. Promotion candidates.** Markdown >= 2 KB and older than 14 days is held |
|||
*out* of quarantine, because an age rule must never silently bin a findings |
|||
doc. For each, decide: does this belong in the Obsidian vault (a durable |
|||
finding, design note, or decision record), in a real repo (a script worth |
|||
keeping), or is it genuinely spent? Recommend a destination; never move it |
|||
yourself without the user agreeing. |
|||
|
|||
**2. Supersession groups.** Numbered siblings - `parse.js` / `parse2.js` / |
|||
`parse3.js`, `render_v2.py` / `render_v4.py`, `smap-check.ps1` / |
|||
`smap-check2.ps1`. Newest-wins is the usual answer but not always right; the |
|||
older one occasionally holds the approach that actually worked. Present the |
|||
group with dates and sizes and let the user choose. |
|||
|
|||
**3. Large files.** Anything >= 5 MB is reported and never auto-actioned. In |
|||
this folder the space story is almost entirely a handful of `.db` snapshots, so |
|||
"reclaim space" and "reduce clutter" are different jobs with different targets. |
|||
Note that a file whose name contains `backup`, `prenuke`, `restore`, `snapshot` |
|||
or `.bak` is protected outright - those exist precisely to save someone. |
|||
|
|||
### Protection rules (never overridden) |
|||
|
|||
- Any path containing a `.git` segment - repo internals. |
|||
- Any top-level folder carrying `.git`, `node_modules`, `package.json`, `*.dll`, |
|||
`*.psd1`, `*.csproj`, or `requirements.txt` - a vendored tree or checked-out |
|||
repo, protected wholesale. Partial deletion of a module tree is worse than |
|||
leaving it. |
|||
- Names matching `backup|prenuke|restore|snapshot|\.bak$`. |
|||
- Everything already under `_trash\`. |
|||
|
|||
### Run report |
|||
|
|||
Lead with what changed, then what needs a decision: |
|||
|
|||
``` |
|||
Scratchpad cleanup - 2026-09-14 |
|||
Reclaimed: 5 log files, 2 empty dirs |
|||
Quarantined: 14 files -> _trash\2026-09-14 (restorable until 2026-10-14) |
|||
|
|||
Needs your call: |
|||
Promote? 5 findings docs, 34d idle - e.g. "SMAP-Kusto-Findings.md" |
|||
Superseded? parse.js/parse2.js/parse3.js - keep parse3 only? |
|||
Large: bellwether-prenuke-*.db (20.8 MB) - protected, but is it still needed? |
|||
``` |
|||
|
|||
Silence is fine. If nothing was reclaimed and nothing needs a decision, say so |
|||
in one line rather than padding the report. |
|||
|
|||
### Guardrails |
|||
|
|||
- Never act outside `D:\Repos\Scratchpad`. |
|||
- Never delete anything that has not been through quarantine first, except |
|||
`.log` / `.err` files and empty directories. |
|||
- `apply.ps1` is dry-run by default. Do not pass `-Execute` to a mode you have |
|||
not previewed in the same run. |
|||
- `MaxQuarantinePerRun` (60) is a deliberate circuit breaker. If it trips, do |
|||
NOT raise it to get past it - it means the age threshold changed and is about |
|||
to sweep the folder. Check `scan.ps1 -ReportOnlyAges` and report to the user |
|||
instead. |
|||
- Never move a promotion candidate into the vault unprompted; the vault has its |
|||
own schema rules (see `D:\Repos\Obsidian\AGENTS.md`) and an unannounced write |
|||
there will fail lint. |
|||
|
|||
### Common Mistakes |
|||
|
|||
- Lowering the age threshold without checking `-ReportOnlyAges` first. The |
|||
distribution is lumpy: a single day's work can put 100+ files in one band, so |
|||
a small threshold change can be the difference between 14 files and 147. |
|||
- Treating `.db` / `.tm7` / `.excalidraw` as junk because they are large or |
|||
binary. They are usually the most expensive artifacts in the folder. |
|||
- Deleting an empty directory inside `.git` - it corrupts the repo. The scan |
|||
already excludes these; do not re-add them by hand. |
|||
@ -0,0 +1,154 @@ |
|||
<# |
|||
.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 } |
|||
@ -0,0 +1,244 @@ |
|||
<# |
|||
.SYNOPSIS |
|||
Deterministic classifier for the Scratchpad folder. Read-only. |
|||
|
|||
.DESCRIPTION |
|||
Emits JSON describing what cleanup *would* touch. Writes nothing, moves |
|||
nothing, deletes nothing. apply.ps1 consumes this; the scratchpad-cleanup |
|||
skill reads it to make the judgment calls the script deliberately avoids. |
|||
|
|||
Every mechanical decision (age, size, vendored-tree detection, supersession |
|||
grouping, trash expiry) lives here so it is reproducible. Anything requiring |
|||
taste - "is this findings doc worth promoting?" - is only ever *flagged*. |
|||
|
|||
.EXAMPLE |
|||
.\scan.ps1 | ConvertFrom-Json |
|||
.\scan.ps1 -Root D:\Repos\Scratchpad -QuarantineAfterDays 45 |
|||
#> |
|||
[CmdletBinding()] |
|||
param( |
|||
[string] $Root = 'D:\Repos\Scratchpad', |
|||
|
|||
# Files older than this (and otherwise eligible) become quarantine candidates. |
|||
# Default 45: Daniel's folder has a dense cluster at ~33 days, so 30 would |
|||
# sweep 147 files in one run while 45 takes 14. Re-check with -ReportOnlyAges |
|||
# before lowering this. |
|||
[int] $QuarantineAfterDays = 45, |
|||
|
|||
# How long a _trash\<date> folder survives before apply.ps1 will purge it. |
|||
[int] $TrashGraceDays = 30, |
|||
|
|||
# Files at or above this size are always reported, never auto-actioned. |
|||
[int] $LargeFileMB = 5, |
|||
|
|||
# Markdown at or above this size, older than PromoteMinAgeDays, is treated as |
|||
# a possible durable finding and held back from quarantine. |
|||
[int] $PromoteMinKB = 2, |
|||
[int] $PromoteMinAgeDays = 14, |
|||
|
|||
# Print an age-threshold histogram instead of the full scan. Use this before |
|||
# changing QuarantineAfterDays. |
|||
[switch] $ReportOnlyAges |
|||
) |
|||
|
|||
$ErrorActionPreference = 'Stop' |
|||
|
|||
if (-not (Test-Path -LiteralPath $Root)) { throw "Scratchpad root not found: $Root" } |
|||
$Root = (Resolve-Path -LiteralPath $Root).Path |
|||
$now = Get-Date |
|||
$trashRoot = Join-Path $Root '_trash' |
|||
|
|||
# --- name patterns that mean "this file exists to protect something" ---------- |
|||
# bellwether-prenuke-*.db is literally a pre-destructive-operation snapshot. |
|||
# Never let an age rule reach these. |
|||
$protectedNamePattern = 'backup|prenuke|restore|snapshot|\.bak$' |
|||
|
|||
# A top-level folder is a vendored dependency tree or a checked-out git repo |
|||
# (not scratch output) if it carries any of these markers. Protect it wholesale - |
|||
# partial deletion of a module tree or a repo is worse than leaving it. |
|||
$vendorMarkers = @('.git', 'node_modules', 'package.json', '*.dll', '*.psd1', '*.csproj', 'requirements.txt') |
|||
|
|||
$purgeExtensions = @('.log', '.err') |
|||
|
|||
function Get-Rel([string] $FullName) { $FullName.Substring($Root.Length).TrimStart('\') } |
|||
function Get-Age($file) { [int]((New-TimeSpan -Start $file.LastWriteTime -End $now).TotalDays) } |
|||
|
|||
$allFiles = @(Get-ChildItem -LiteralPath $Root -Recurse -File -Force -ErrorAction SilentlyContinue) |
|||
$allDirs = @(Get-ChildItem -LiteralPath $Root -Recurse -Directory -Force -ErrorAction SilentlyContinue) |
|||
|
|||
if ($ReportOnlyAges) { |
|||
foreach ($t in 15, 30, 45, 60, 90, 120) { |
|||
$hit = @($allFiles | Where-Object { (Get-Age $_) -gt $t }) |
|||
'{0,4}d -> {1,4} files {2,8} MB' -f $t, $hit.Count, [math]::Round((($hit | Measure-Object Length -Sum).Sum) / 1MB, 2) |
|||
} |
|||
return |
|||
} |
|||
|
|||
# --- identify vendored top-level dirs ---------------------------------------- |
|||
$vendorDirs = [System.Collections.Generic.HashSet[string]]::new() |
|||
foreach ($d in (Get-ChildItem -LiteralPath $Root -Directory -Force -ErrorAction SilentlyContinue)) { |
|||
if ($d.Name -eq '_trash') { continue } |
|||
foreach ($m in $vendorMarkers) { |
|||
if (Get-ChildItem -LiteralPath $d.FullName -Recurse -Force -Filter $m -ErrorAction SilentlyContinue | Select-Object -First 1) { |
|||
[void]$vendorDirs.Add($d.Name) |
|||
break |
|||
} |
|||
} |
|||
} |
|||
|
|||
function Test-Protected($file) { |
|||
$rel = Get-Rel $file.FullName |
|||
$segments = $rel -split '\\' |
|||
$top = $segments[0] |
|||
if ($top -eq '_trash') { return 'trash' } |
|||
# Never touch anything inside a git repo's metadata, at any depth. |
|||
if ($segments -contains '.git') { return 'git-internals' } |
|||
if ($vendorDirs.Contains($top)) { return 'vendored' } |
|||
if ($file.Name -match $protectedNamePattern) { return 'protective-name' } |
|||
return $null |
|||
} |
|||
|
|||
$buckets = @{ |
|||
protected = [System.Collections.ArrayList]::new() |
|||
autoPurgeFiles = [System.Collections.ArrayList]::new() |
|||
emptyDirs = [System.Collections.ArrayList]::new() |
|||
quarantine = [System.Collections.ArrayList]::new() |
|||
promoteCandidates = [System.Collections.ArrayList]::new() |
|||
largeFiles = [System.Collections.ArrayList]::new() |
|||
supersession = [System.Collections.ArrayList]::new() |
|||
trashExpired = [System.Collections.ArrayList]::new() |
|||
keep = [System.Collections.ArrayList]::new() |
|||
} |
|||
|
|||
function New-Entry($file, $reason) { |
|||
[PSCustomObject]@{ |
|||
rel = Get-Rel $file.FullName |
|||
name = $file.Name |
|||
ageDays = Get-Age $file |
|||
kb = [math]::Round($file.Length / 1KB, 1) |
|||
reason = $reason |
|||
} |
|||
} |
|||
|
|||
foreach ($f in $allFiles) { |
|||
$rel = Get-Rel $f.FullName |
|||
$top = ($rel -split '\\')[0] |
|||
|
|||
# _trash is handled separately by folder, not per-file. |
|||
if ($top -eq '_trash') { continue } |
|||
|
|||
$age = Get-Age $f |
|||
|
|||
if ($f.Length -ge ($LargeFileMB * 1MB)) { |
|||
[void]$buckets.largeFiles.Add((New-Entry $f ">= $LargeFileMB MB - report only, never auto-actioned")) |
|||
} |
|||
|
|||
$prot = Test-Protected $f |
|||
if ($prot) { [void]$buckets.protected.Add((New-Entry $f $prot)); continue } |
|||
|
|||
if ($purgeExtensions -contains $f.Extension.ToLowerInvariant()) { |
|||
[void]$buckets.autoPurgeFiles.Add((New-Entry $f "disposable byproduct ($($f.Extension))")) |
|||
continue |
|||
} |
|||
|
|||
# Durable-looking markdown is held back from quarantine so findings are never |
|||
# silently binned by an age rule. The skill decides promote vs drop. |
|||
if ($f.Extension -ieq '.md' -and $f.Length -ge ($PromoteMinKB * 1KB) -and $age -gt $PromoteMinAgeDays) { |
|||
[void]$buckets.promoteCandidates.Add((New-Entry $f "markdown >= $PromoteMinKB KB, ${age}d old - judgment required")) |
|||
continue |
|||
} |
|||
|
|||
if ($age -gt $QuarantineAfterDays) { |
|||
[void]$buckets.quarantine.Add((New-Entry $f "stale: ${age}d > ${QuarantineAfterDays}d")) |
|||
continue |
|||
} |
|||
|
|||
[void]$buckets.keep.Add((New-Entry $f 'active')) |
|||
} |
|||
|
|||
# --- empty directories -------------------------------------------------------- |
|||
foreach ($d in $allDirs) { |
|||
$rel = Get-Rel $d.FullName |
|||
$segments = $rel -split '\\' |
|||
if ($segments[0] -eq '_trash') { continue } |
|||
if ($segments -contains '.git') { continue } |
|||
if ($vendorDirs.Contains($segments[0])) { continue } |
|||
if (-not (Get-ChildItem -LiteralPath $d.FullName -Recurse -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) { |
|||
[void]$buckets.emptyDirs.Add([PSCustomObject]@{ rel = $rel; reason = 'empty directory' }) |
|||
} |
|||
} |
|||
|
|||
# --- supersession groups ------------------------------------------------------ |
|||
# Group numbered iterations with their unnumbered base: parse.js / parse2.js / |
|||
# parse3.js, render_v2.py / render_v4.py, smap-check.ps1 / smap-check2.ps1. |
|||
# Reported only - "newest wins" is usually right but not always, so a human calls it. |
|||
$groups = @{} |
|||
foreach ($f in $allFiles) { |
|||
$rel = Get-Rel $f.FullName |
|||
$top = ($rel -split '\\')[0] |
|||
if ($top -eq '_trash' -or $vendorDirs.Contains($top)) { continue } |
|||
$stem = $f.BaseName -replace '[ _-]?[vV]?\d+$', '' |
|||
if ([string]::IsNullOrWhiteSpace($stem)) { continue } |
|||
$key = '{0}|{1}|{2}' -f (Split-Path -Parent $rel), $stem, $f.Extension.ToLowerInvariant() |
|||
if (-not $groups.ContainsKey($key)) { $groups[$key] = [System.Collections.ArrayList]::new() } |
|||
[void]$groups[$key].Add($f) |
|||
} |
|||
foreach ($k in $groups.Keys) { |
|||
$members = @($groups[$k]) |
|||
if ($members.Count -lt 2) { continue } |
|||
$sorted = @($members | Sort-Object LastWriteTime -Descending) |
|||
[void]$buckets.supersession.Add([PSCustomObject]@{ |
|||
stem = ($k -split '\|')[1] |
|||
ext = ($k -split '\|')[2] |
|||
newest = (Get-Rel $sorted[0].FullName) |
|||
members = @($sorted | ForEach-Object { |
|||
[PSCustomObject]@{ |
|||
rel = Get-Rel $_.FullName |
|||
ageDays = Get-Age $_ |
|||
kb = [math]::Round($_.Length / 1KB, 1) |
|||
isNewest = ($_.FullName -eq $sorted[0].FullName) |
|||
} |
|||
}) |
|||
}) |
|||
} |
|||
|
|||
# --- expired trash ------------------------------------------------------------ |
|||
if (Test-Path -LiteralPath $trashRoot) { |
|||
foreach ($d in (Get-ChildItem -LiteralPath $trashRoot -Directory -ErrorAction SilentlyContinue)) { |
|||
$stamp = [datetime]::MinValue |
|||
if ([datetime]::TryParseExact($d.Name, 'yyyy-MM-dd', $null, [Globalization.DateTimeStyles]::None, [ref]$stamp)) { |
|||
$ageDays = [int]((New-TimeSpan -Start $stamp -End $now).TotalDays) |
|||
if ($ageDays -gt $TrashGraceDays) { |
|||
$n = @(Get-ChildItem -LiteralPath $d.FullName -Recurse -File -ErrorAction SilentlyContinue).Count |
|||
[void]$buckets.trashExpired.Add([PSCustomObject]@{ rel = "_trash\$($d.Name)"; ageDays = $ageDays; files = $n }) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
[PSCustomObject]@{ |
|||
root = $Root |
|||
scannedAt = $now.ToString('o') |
|||
config = [PSCustomObject]@{ |
|||
quarantineAfterDays = $QuarantineAfterDays |
|||
trashGraceDays = $TrashGraceDays |
|||
largeFileMB = $LargeFileMB |
|||
vendoredDirs = @($vendorDirs) |
|||
} |
|||
totals = [PSCustomObject]@{ |
|||
files = $allFiles.Count |
|||
mb = [math]::Round((($allFiles | Measure-Object Length -Sum).Sum) / 1MB, 2) |
|||
} |
|||
counts = [PSCustomObject]@{ |
|||
protected = $buckets.protected.Count |
|||
autoPurgeFiles = $buckets.autoPurgeFiles.Count |
|||
emptyDirs = $buckets.emptyDirs.Count |
|||
quarantine = $buckets.quarantine.Count |
|||
promoteCandidates = $buckets.promoteCandidates.Count |
|||
largeFiles = $buckets.largeFiles.Count |
|||
supersession = $buckets.supersession.Count |
|||
trashExpired = $buckets.trashExpired.Count |
|||
keep = $buckets.keep.Count |
|||
} |
|||
buckets = [PSCustomObject]$buckets |
|||
} | ConvertTo-Json -Depth 8 |
|||
Loading…
Reference in new issue