diff --git a/claudearium.ps1 b/claudearium.ps1 index 747b58d..3b1b5d3 100644 --- a/claudearium.ps1 +++ b/claudearium.ps1 @@ -24,6 +24,8 @@ param( [string]$HostCheckout, [string]$To, [switch]$DiscardDirty, + [string]$Scope, + [switch]$DryRun, [string]$HostPath, [string]$Guest, [string]$Mode, @@ -87,6 +89,7 @@ Import-Module (Join-Path $Script:ModulesDir 'ClaudeFile.psm1') -Force Import-Module (Join-Path $Script:ModulesDir 'HostToolNotes.psm1') -Force Import-Module (Join-Path $Script:ModulesDir 'SelfUpdate.psm1') -Force Import-Module (Join-Path $Script:ModulesDir 'ToolUpdates.psm1') -Force +Import-Module (Join-Path $Script:ModulesDir 'Prune.psm1') -Force Set-VpnPayloadRoot -Path $Script:PayloadDir # Snapshot the wt tab title so we can prefix it with '*' when tool updates are @@ -106,6 +109,8 @@ Verbs: status Show distro and sandbox state. nuke Unregister the distro and remove all sandbox state. reconcile Diff profile against state; prompt; apply. + prune [-Scope ] Drift detection + repair. -Scope sessions|worktrees| + mounts|artifacts|all. -DryRun to report only. profile validate Validate a profile (or the default profile if omitted). profile export -Out

Write current state to a profile file at

. @@ -123,6 +128,11 @@ Verbs: project list Table of projects (profile + materialization status). project remove Delete bare mirror (or per-project bin dir for hostProjects), sessions, and profile entry. + project move Migrate a project between distro and host types in place. + -To host -HostCheckout

: distro -> host. + -To distro [-Remote ]: host -> distro (auto-detects + Remote from the existing hostCheckout's origin). + Refuses dirty sessions unless -DiscardDirty / -Force. project show Inspect a project's profile entry + mirror status. session Bare = interactive dashboard. @@ -1101,6 +1111,200 @@ function Invoke-Reconcile { } } +function Invoke-Prune { + # Drift detection + repair. Four scopes: + # sessions — state.sessions records whose worktree dir is gone + # worktrees — git's own worktree list flags `prunable`, or the dir is missing + # mounts — fstab managed-block entries with no matching host session + # artifacts — heavy untracked dirs (node_modules / target / .next / ...) + # inside live session worktrees + # -DryRun reports what *would* happen and exits without mutating anything. + # -Force skips per-item prompts on the destructive scopes (artifacts). + # -Scope narrows the run; default is 'all'. + $scope = if ($Scope) { $Scope.ToLowerInvariant() } else { 'all' } + $validScopes = @('all', 'sessions', 'worktrees', 'mounts', 'artifacts') + if ($scope -notin $validScopes) { + throw "prune: -Scope must be one of: $($validScopes -join ', ') (got '$Scope')." + } + $distro = Resolve-DistroForOps + if (-not (Test-DistroExists -Name $distro)) { + throw "Distro '$distro' does not exist; nothing to prune." + } + $spec = $null + try { $spec = Read-ProfileIfPresent } catch { } + $state = if (Test-State -DistroName $distro) { Read-State -DistroName $distro } else { Initialize-State -DistroName $distro } + + $runScope = { param([string]$Name) ($scope -eq 'all' -or $scope -eq $Name) } + $anyAction = $false + $stateMutated = $false + + Write-Host '' + Write-Host "=== Claudearium prune (scope: $scope$(if ($DryRun) { ', dry-run' }))===" -ForegroundColor Cyan + + # ---- sessions scope ---- + if (& $runScope 'sessions') { + Write-Host '' + Write-Host '[sessions] Looking for state.sessions records whose worktree is gone ...' + # @(...) wrap: Find-* helpers return `@()` for the no-results case, + # which PowerShell unwraps to $null at the assignment boundary — + # `$null.Count` then throws under StrictMode. The wrap converts the + # unwrapped result back into an empty Object[]. + $orphans = @(Find-OrphanedSessions -State $state -DistroName $distro) + if ($orphans.Count -eq 0) { + Write-Host ' no orphaned sessions.' -ForegroundColor DarkGray + } + else { + $anyAction = $true + foreach ($o in $orphans) { + $where = if ($o.Type -eq 'host') { $o.HostWorktreePath } else { $o.WorktreePath } + Write-Host (" orphan: {0,-16} {1,-22} ({2}) worktree gone at: {3}" -f $o.Project, $o.Name, $o.Type, $where) -ForegroundColor Yellow + } + if (-not $DryRun) { + foreach ($o in $orphans) { + $state.sessions = @($state.sessions | Where-Object { -not ([string]$_.project -eq $o.Project -and [string]$_.name -eq $o.Name) }) + } + $stateMutated = $true + Write-Host " removed $($orphans.Count) state record(s)." -ForegroundColor Green + } + } + } + + # ---- worktrees scope ---- + if (& $runScope 'worktrees') { + Write-Host '' + Write-Host '[worktrees] Looking for stale git worktree refs ...' + $stale = @(Find-StaleWorktrees -DistroName $distro -ProfileSpec $spec) + if ($stale.Count -eq 0) { + Write-Host ' no stale worktree refs.' -ForegroundColor DarkGray + } + else { + $anyAction = $true + # Group by Location so we can batch `git worktree prune` per repo + # (it cleans every stale ref in one pass; no per-worktree call). + $byLocation = @{} + foreach ($s in $stale) { + if (-not $byLocation.ContainsKey($s.Location)) { $byLocation[$s.Location] = New-Object System.Collections.Generic.List[hashtable] } + $byLocation[$s.Location].Add($s) + } + foreach ($loc in $byLocation.Keys) { + Write-Host " $loc :" -ForegroundColor Yellow + foreach ($s in $byLocation[$loc]) { + Write-Host (" {0} ({1})" -f $s.Worktree, $s.Reason) + } + } + if (-not $DryRun) { + foreach ($loc in $byLocation.Keys) { + $side = $byLocation[$loc][0].Side + if ($side -eq 'distro') { + $qLoc = ConvertTo-BashQuoted $loc + Invoke-InDistro -Name $distro -User 'claude' -Command "git -C $qLoc worktree prune" -AllowFail | Out-Null + } + else { + # host side — run git on the Windows checkout directly. + & git -C $loc worktree prune 2>$null | Out-Null + } + } + Write-Host " pruned $($stale.Count) stale ref(s) across $($byLocation.Keys.Count) repo(s)." -ForegroundColor Green + } + } + } + + # ---- mounts scope ---- + if (& $runScope 'mounts') { + Write-Host '' + Write-Host '[mounts] Looking for dangling fstab entries (no matching host session) ...' + $dangling = @(Find-DanglingMounts -DistroName $distro -State $state -ProfileSpec $spec) + if ($dangling.Count -eq 0) { + Write-Host ' no dangling fstab entries.' -ForegroundColor DarkGray + } + else { + $anyAction = $true + foreach ($d in $dangling) { + Write-Host (" dangling: {0,-32} {1}" -f $d.Guest, $d.Host) -ForegroundColor Yellow + } + if (-not $DryRun) { + # Persist any prior state mutation first so Invoke-MergedMountsApply's + # Read-State call sees the just-pruned sessions. + if ($stateMutated) { + Write-State -DistroName $distro -State $state + $stateMutated = $false + } + Invoke-MergedMountsApply -DistroName $distro + Write-Host " fstab managed block rewritten." -ForegroundColor Green + } + } + } + + # ---- artifacts scope ---- + if (& $runScope 'artifacts') { + Write-Host '' + Write-Host '[artifacts] Scanning session worktrees for heavy build dirs ...' + $artifacts = @(Find-HeavyArtifacts -DistroName $distro -State $state) + # Drop anything below a tiny threshold (5MB) — empty `bin/` and `obj/` + # dirs from PowerShell modules add noise without real disk impact. + $artifacts = @($artifacts | Where-Object { $_.Bytes -ge (5 * 1MB) }) + if ($artifacts.Count -eq 0) { + Write-Host ' no heavy artifact dirs found.' -ForegroundColor DarkGray + } + else { + $anyAction = $true + $totalBytes = 0L; foreach ($a in $artifacts) { $totalBytes += [long]$a.Bytes } + Write-Host " found $($artifacts.Count) heavy dir(s); total $((Format-Bytes -Bytes $totalBytes))" -ForegroundColor Yellow + foreach ($a in $artifacts) { + Write-Host (" {0,-7} {1,-16} {2,-22} {3,-16} {4}" -f (Format-Bytes -Bytes $a.Bytes), $a.Project, $a.Session, $a.ArtifactDir, $a.Path) + } + if (-not $DryRun) { + foreach ($a in $artifacts) { + $label = "$($a.Project)/$($a.Session)/$($a.ArtifactDir)" + $delete = $Force + if (-not $delete) { + $delete = Read-YesNo -Prompt "Delete $label ($((Format-Bytes -Bytes $a.Bytes)))?" -Default $false -NonInteractive:$NonInteractive + } + if (-not $delete) { + Write-Host " skipped $label." -ForegroundColor DarkGray + continue + } + if ($a.Type -eq 'host') { + $target = Join-Path $a.Path $a.ArtifactDir + try { + Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction Stop + Write-Host " removed $label." -ForegroundColor Green + } catch { + Write-Host " failed: $label — $($_.Exception.Message)" -ForegroundColor Red + } + } + else { + $qTarget = ConvertTo-BashQuoted "$($a.Path)/$($a.ArtifactDir)" + $r = Invoke-InDistro -Name $distro -User 'claude' -Command "rm -rf $qTarget && echo ok || echo fail" -AllowFail -CaptureOutput + $verdict = ($r.Output | Where-Object { $_ -is [string] -and ($_.Trim() -in @('ok','fail')) } | Select-Object -Last 1) -as [string] + if ($verdict -and $verdict.Trim() -eq 'ok') { + Write-Host " removed $label." -ForegroundColor Green + } + else { + Write-Host " failed: $label" -ForegroundColor Red + } + } + } + } + } + } + + # ---- final state persistence ---- + if ($stateMutated -and -not $DryRun) { + Write-State -DistroName $distro -State $state + } + Write-Host '' + if (-not $anyAction) { + Write-Host 'Nothing to prune.' -ForegroundColor Green + } + elseif ($DryRun) { + Write-Host 'Dry-run complete — no changes made. Re-run without -DryRun to apply.' -ForegroundColor Cyan + } + else { + Write-Host 'Prune complete.' -ForegroundColor Green + } +} + function Invoke-ProfileValidate { $path = if ($Arg) { $Arg } else { Resolve-ProfilePath } Write-Host '' @@ -3492,6 +3696,7 @@ try { 'status' { Invoke-Status } 'nuke' { Invoke-Nuke } 'reconcile' { Invoke-Reconcile } + 'prune' { Invoke-Prune } 'profile' { Invoke-Profile } 'project' { Invoke-Project } 'session' { Invoke-Session } diff --git a/docs/architecture.md b/docs/architecture.md index 6603499..f2de56b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,6 +45,7 @@ sidestep argv mangling — see [wsl2-gotchas.md](./wsl2-gotchas.md#1-wslexe-argv │ ├── Projects.psm1 # bare-mirror clones + profile mutation │ ├── Sessions.psm1 # git-worktree-per-session + Get-RecentBranches │ ├── Mounts.psm1 # drvfs host mounts via /etc/fstab managed block +│ ├── Prune.psm1 # drift detection (orphan sessions, stale worktrees, dangling mounts, heavy artifacts) │ ├── Tools.psm1 # tool catalog (handler registry) │ ├── ToolUpdates.psm1 # latest-upstream-version cache + background refresh │ ├── HostTools.psm1 # WSL-interop wrappers for Windows .exe @@ -158,7 +159,7 @@ exit 0 # avoid $LASTEXITCODE leak from internal `command -v` probes | Category | Verbs | |---|---| | Lifecycle | `setup`, `status`, `nuke`, `update {check\|apply\|status}`, `diagnostics` | -| Declarative | `reconcile`, `profile {validate\|export\|edit\|show}` | +| Declarative | `reconcile`, `prune {sessions\|worktrees\|mounts\|artifacts\|all}`, `profile {validate\|export\|edit\|show}` | | Repo work | `project {add\|list\|remove\|move\|show}` (+ bare dashboard), `session {new\|list\|remove}` (+ bare dashboard) | | Distro plumbing | `mount {add\|list\|remove\|sync}` (+ bare dashboard) | | Toolchain | `tools {list\|install\|enable\|disable\|sync\|attach}` (+ bare dashboard) | diff --git a/docs/cookbook.md b/docs/cookbook.md index 737c100..b413c8b 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -217,6 +217,29 @@ Cleanup: # Per-project bin dir is gone. C:\GitHub\Claudearium itself is untouched. ``` +### Reclaim disk and resync after manual edits with `prune` + +When you've been editing things outside the tool — `rm -rf` on a worktree, +hand-editing `/etc/fstab`, building a heavy `node_modules` directory inside +a session — `prune` reports the drift and offers to repair it. Start with +a dry run to see what's there: + +```powershell +.\claudearium.ps1 prune -DryRun # everything, report only +.\claudearium.ps1 prune -Scope artifacts -DryRun # just the build-bloat scan +``` + +Then re-run without `-DryRun` to apply. The destructive scope is `artifacts` +(it `rm -rf`s `node_modules` / `target` / etc.); it prompts per directory +unless you pass `-Force`. The other scopes (`sessions`, `worktrees`, +`mounts`) are state / fstab edits — fast and reversible by re-running +`reconcile` or `session new`. + +```powershell +.\claudearium.ps1 prune -Scope artifacts -Force # nuke all heavy build dirs +.\claudearium.ps1 prune # run everything interactively +``` + ### Convert a project between distro-resident and host-resident When a repo's needs change — say, you originally cloned it as a `distroProject` diff --git a/docs/testing.md b/docs/testing.md index fed6f3f..d0c285f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -30,9 +30,9 @@ on any failure — that's the form the GitHub Actions workflow uses. Headline numbers as of this writing — Pester `It`-block counts for the auto lanes (the manifest entries are coarser; each entry is a test *file* that typically contains 3–10 individual assertions): -**311 pure** + **40 distro** = ~351 auto checks. The 4 **manual** entries +**366 pure** + **43 distro** = ~409 auto checks. The 4 **manual** entries in the manifest aren't Pester `It` blocks — they're y/n prompts wired -through `Invoke-ManualTest` — bringing the suite total to ~322 checks. CI runs parse-check + pure on +through `Invoke-ManualTest` — bringing the suite total to ~413 checks. CI runs parse-check + pure on every push to any branch; the distro lane runs on PRs and on `master`. Manual is opt-in (never in CI); diag is on-demand. diff --git a/docs/usage.md b/docs/usage.md index 256ca82..7e4075b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -92,6 +92,20 @@ Smart defaults pull from `-HostCheckout`'s `origin` URL (or the current working Refuses if any session has uncommitted work, unless `-DiscardDirty` (or `-Force`) is set. A timestamped profile snapshot (`claudearium.profile.json.bak-`) is written next to the profile before any mutation, for hand recovery. +## `prune [-Scope ] [-DryRun] [-Force]` + +Detect drift between state, profile, and the distro filesystem; repair (or, with `-DryRun`, just report). `-Scope` narrows the run to one area — defaults to `all`: + +| Scope | Detects | Repair | +|---|---|---| +| `sessions` | state.sessions records whose worktree directory is gone | drop the orphaned records from state | +| `worktrees` | `git worktree list --porcelain` entries with `prunable` set or pointing at a missing dir | `git worktree prune` per mirror / host checkout | +| `mounts` | `/etc/fstab` managed-block entries with no matching host session | rebuild the managed block from the merged-desired set | +| `artifacts` | heavy untracked build dirs (`node_modules`, `target`, `.next`, `dist`, `build`, `out`, `obj`, `bin`) inside live session worktrees, ≥ 5 MB each | `rm -rf` per dir, prompts unless `-Force` | +| `all` | every scope above | every repair above | + +`-DryRun` prints the diagnosis and exits without mutating anything; safe to run anytime. `-Force` skips the per-item confirmation in the `artifacts` scope. The other scopes apply en masse — there's nothing reversible there beyond what `git` and `wsl --shutdown` already give you. + **Disabling a project without removing it.** Edit the profile entry to add `"enabled": false`, or use the dashboard's `t ` toggle. The next `reconcile` tears down the materialized infrastructure (mirror or per-project bin dir + every session of the project), but leaves the profile entry alone so the `tabColor`, `defaultBranch`, `hostShadows`, etc. survive. Flip back to `"enabled": true` (or remove the field) and re-reconcile to recreate everything. Disable is destructive for sessions exactly like a full remove — uncommitted work in worktrees is lost. ## `session ` diff --git a/modules/Prune.psm1 b/modules/Prune.psm1 new file mode 100644 index 0000000..9a99158 --- /dev/null +++ b/modules/Prune.psm1 @@ -0,0 +1,342 @@ +# Prune.psm1 +# Drift detection between state.json / profile.json / the distro filesystem. +# Each Find-* function reports what *exists* but *shouldn't* (or vice-versa); +# the verb's repair half lives in claudearium.ps1 and uses the existing +# Remove-/Invoke-MergedMountsApply primitives. +# +# Scopes: +# sessions — state.sessions records whose worktree no longer exists +# (either side); repair = drop the record. +# worktrees — bare-mirror or host-checkout `git worktree list` entries +# whose worktree dir is gone; repair = `git worktree prune`. +# mounts — fstab managed-block entries with no matching `type:'host'` +# session in state; repair = Invoke-MergedMountsApply. +# artifacts — heavy untracked dirs (node_modules / target / .next / dist / +# build / out / obj / bin) inside live session worktrees; +# repair = `rm -rf` (per dir, prompted). +# +# Public surface: +# Find-OrphanedSessions -State -DistroName — @( @{ Project; Name; Type; WorktreePath; HostWorktreePath } ) +# Find-StaleWorktrees -DistroName -ProfileSpec — @( @{ Location; Worktree; Side } ) +# Find-DanglingMounts -DistroName -State -ProfileSpec — @( @{ Guest; Host } ) — actual fstab minus merged-desired +# Find-HeavyArtifacts -DistroName -State — @( @{ Project; Session; Type; Path; ArtifactDir; Bytes } ) +# Format-Bytes -Bytes — '1.2G' / '450M' / etc. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'Wsl.psm1') +Import-Module (Join-Path $PSScriptRoot 'Sessions.psm1') +Import-Module (Join-Path $PSScriptRoot 'Mounts.psm1') + +# Single source of truth for which untracked subdirectories count as "build +# artifacts" worth surfacing. The verb prompts per-dir, but having them all +# here keeps the test fixture and the docs in sync. +$Script:KnownArtifactDirs = @('node_modules', 'target', '.next', 'dist', 'build', 'out', 'obj', 'bin') + +function Format-Bytes { + # Human-readable size. SI-style suffixes (G/M/K) at one decimal place. + [CmdletBinding()] + param([Parameter(Mandatory)][long]$Bytes) + if ($Bytes -lt 1024) { return "$Bytes B" } + if ($Bytes -lt 1MB) { return ('{0:N1}K' -f ($Bytes / 1KB)) } + if ($Bytes -lt 1GB) { return ('{0:N1}M' -f ($Bytes / 1MB)) } + return ('{0:N1}G' -f ($Bytes / 1GB)) +} + +function Find-OrphanedSessions { + # state.sessions records whose worktree directory has vanished. For distro + # sessions, that's a `test -d /home/claude/projects/

/sessions/` in + # the distro; for host sessions, Test-Path on hostWorktreePath (Windows + # side). Either lookup can fail benignly (distro stopped, host path + # mounted on a network drive that's currently offline) — we treat + # failures as "exists" so we don't drop records we couldn't verify. + [CmdletBinding()] + param( + [Parameter(Mandatory)][hashtable]$State, + [Parameter(Mandatory)][string]$DistroName + ) + $orphans = New-Object System.Collections.Generic.List[hashtable] + foreach ($s in (Get-Sessions -State $State)) { + if (-not ($s -is [hashtable])) { continue } + $type = Get-SessionType -Session $s + $proj = [string]$s.project + $name = [string]$s.name + $missing = $false + + if ($type -eq 'host') { + $hostWt = if ($s.ContainsKey('hostWorktreePath')) { [string]$s.hostWorktreePath } else { $null } + if ($hostWt -and -not (Test-Path -LiteralPath $hostWt -PathType Container)) { $missing = $true } + } + else { + $wt = if ($s.ContainsKey('worktreePath')) { [string]$s.worktreePath } else { $null } + if ($wt) { + $q = ConvertTo-BashQuoted $wt + $r = Invoke-InDistro -Name $DistroName -User 'claude' -Command "test -d $q && echo present || echo gone" -AllowFail -CaptureOutput + if ($r.ExitCode -eq 0) { + $verdict = (($r.Output | Where-Object { $_ -is [string] -and ($_.Trim() -in @('present', 'gone')) } | Select-Object -Last 1) -as [string]) + if ($verdict -and $verdict.Trim() -eq 'gone') { $missing = $true } + } + } + } + + if ($missing) { + $orphans.Add(@{ + Project = $proj + Name = $name + Type = $type + WorktreePath = if ($s.ContainsKey('worktreePath')) { [string]$s.worktreePath } else { '' } + HostWorktreePath = if ($s.ContainsKey('hostWorktreePath')) { [string]$s.hostWorktreePath } else { '' } + }) + } + } + # Always return a plain enumerable: PowerShell unwraps a single-element + # array at the call site, but callers wrap with `@(...)` to re-array-ify + # — `,@(array)` from here would survive that wrap by getting nested, + # which breaks the foreach iteration on the outer. + return $orphans.ToArray() +} + +function Find-StaleWorktrees { + # For each project, parse `git worktree list --porcelain` and report any + # entries whose `worktree ` line points at a directory that no + # longer exists. distroProjects: run against /home/claude/mirrors/

.git + # inside the distro. hostProjects: run against hostCheckout on Windows + # side. Both git invocations also surface `prunable` markers (worktrees + # git itself already knows are stale) — those count regardless of whether + # the dir is still on disk. + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$DistroName, + [AllowNull()][hashtable]$ProfileSpec + ) + $result = New-Object System.Collections.Generic.List[hashtable] + + # ---- distroProjects (mirrors inside the distro) ---- + # Enumerate mirrors via the filesystem rather than the profile so we also + # catch projects that were removed from the profile but whose mirrors + # were edited manually (the same drift that motivates this verb). + $listCmd = '[ -d /home/claude/mirrors ] && find /home/claude/mirrors -maxdepth 1 -name "*.git" -type d -printf "%f\n" || true' + $r = Invoke-InDistro -Name $DistroName -User 'claude' -Command $listCmd -AllowFail -CaptureOutput + $mirrorNames = @() + if ($r.ExitCode -eq 0) { + $mirrorNames = @($r.Output | + Where-Object { $_ -is [string] -and ($_.Trim() -match '^[^\\/\s]+\.git$') } | + ForEach-Object { $_.Trim() -replace '\.git$', '' }) + } + foreach ($projName in $mirrorNames) { + $qPath = ConvertTo-BashQuoted "/home/claude/mirrors/$projName.git" + $wtCmd = "git -C $qPath worktree list --porcelain 2>/dev/null || true" + $wtR = Invoke-InDistro -Name $DistroName -User 'claude' -Command $wtCmd -AllowFail -CaptureOutput + if ($wtR.ExitCode -ne 0) { continue } + $current = $null; $isPrunable = $false + foreach ($line in @($wtR.Output)) { + $s = [string]$line + if ($s -match '^worktree\s+(.+)$') { + # New record begins — flush the previous one first. + if ($current) { + $exists = $true + $qWt = ConvertTo-BashQuoted $current + $exR = Invoke-InDistro -Name $DistroName -User 'claude' -Command "test -d $qWt && echo present || echo gone" -AllowFail -CaptureOutput + if ($exR.ExitCode -eq 0) { + $verdict = (($exR.Output | Where-Object { $_ -is [string] -and ($_.Trim() -in @('present','gone')) } | Select-Object -Last 1) -as [string]) + if ($verdict -and $verdict.Trim() -eq 'gone') { $exists = $false } + } + if (-not $exists -or $isPrunable) { + $result.Add(@{ + Side = 'distro' + Location = "/home/claude/mirrors/$projName.git" + Worktree = $current + Reason = if (-not $exists) { 'worktree-gone' } else { 'prunable' } + }) + } + } + $current = $Matches[1].Trim() + $isPrunable = $false + } + elseif ($s -match '^prunable\s') { $isPrunable = $true } + } + # flush last record + if ($current) { + $exists = $true + $qWt = ConvertTo-BashQuoted $current + $exR = Invoke-InDistro -Name $DistroName -User 'claude' -Command "test -d $qWt && echo present || echo gone" -AllowFail -CaptureOutput + if ($exR.ExitCode -eq 0) { + $verdict = (($exR.Output | Where-Object { $_ -is [string] -and ($_.Trim() -in @('present','gone')) } | Select-Object -Last 1) -as [string]) + if ($verdict -and $verdict.Trim() -eq 'gone') { $exists = $false } + } + # Bare mirrors include themselves as a "worktree" entry (the + # mirror path itself); that entry obviously exists, so the + # presence check above filters it out. We only emit non-existent + # or git-flagged-prunable. + if (-not $exists -or $isPrunable) { + $result.Add(@{ + Side = 'distro' + Location = "/home/claude/mirrors/$projName.git" + Worktree = $current + Reason = if (-not $exists) { 'worktree-gone' } else { 'prunable' } + }) + } + } + } + + # ---- hostProjects (checkouts on the Windows side) ---- + if ($ProfileSpec -and $ProfileSpec.ContainsKey('projects') -and $ProfileSpec.projects) { + foreach ($p in @($ProfileSpec.projects)) { + if (-not ($p -is [hashtable])) { continue } + $ptype = 'distro' + if ($p.ContainsKey('type') -and $p.type) { $ptype = [string]$p.type } + if ($ptype -ne 'host') { continue } + $hc = [string]$p.hostCheckout + if (-not $hc -or -not (Test-Path -LiteralPath $hc -PathType Container)) { continue } + $out = $null + try { + $out = & git -C $hc worktree list --porcelain 2>$null + } catch {} + if ($LASTEXITCODE -ne 0 -or -not $out) { continue } + + # `git worktree list --porcelain` emits one record per worktree, + # records separated by a blank line, starting with `worktree ` + # then optional fields including `prunable `. We accumulate + # the current record as we walk lines and emit on each new + # `worktree` header (and once at end-of-loop for the trailing + # record). The emit-helper returns the entry directly via pipeline + # rather than via a $script:-scope variable — sideband variables + # silently leak state across iterations (gotcha-style). + $processed = { + param($wt, $prunable, $location) + if (-not $wt) { return $null } + $exists = Test-Path -LiteralPath $wt -PathType Container + if (-not $exists -or $prunable) { + return @{ + Side = 'host' + Location = $location + Worktree = $wt + Reason = if (-not $exists) { 'worktree-gone' } else { 'prunable' } + } + } + return $null + } + $current = $null; $isPrunable = $false + foreach ($line in @($out)) { + $s = [string]$line + if ($s -match '^worktree\s+(.+)$') { + $entry = & $processed $current $isPrunable $hc + if ($entry) { $result.Add($entry) } + $current = $Matches[1].Trim() + $isPrunable = $false + } + elseif ($s -match '^prunable\s') { $isPrunable = $true } + } + $entry = & $processed $current $isPrunable $hc + if ($entry) { $result.Add($entry) } + } + } + + return $result.ToArray() +} + +function Find-DanglingMounts { + # fstab managed-block entries whose guest path is not in the merged + # desired set (profile.hostMounts ∪ state.sessions for host projects). + # Repair is Invoke-MergedMountsApply, which rewrites the block from the + # desired set unconditionally. + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$DistroName, + [Parameter(Mandatory)][hashtable]$State, + [AllowNull()][hashtable]$ProfileSpec + ) + $actual = Get-HostMountsActualFromDistro -DistroName $DistroName + $desired = Get-MergedDesiredMounts -ProfileSpec $ProfileSpec -State $State + $desiredByGuest = @{} + foreach ($m in @($desired)) { if ($m) { $desiredByGuest[[string]$m.guest] = $true } } + $dangling = New-Object System.Collections.Generic.List[hashtable] + foreach ($a in @($actual)) { + if (-not $a) { continue } + $g = [string]$a.guest + if (-not $desiredByGuest.ContainsKey($g)) { + $dangling.Add(@{ Guest = $g; Host = [string]$a.host }) + } + } + return $dangling.ToArray() +} + +function Find-HeavyArtifacts { + # Inspect each live session's worktree for the known build-output dirs + # and report their sizes. Distro sessions: `du -sb` (bytes) inside the + # distro. Host sessions: PowerShell directory walk on Windows side. + # Both can be slow for large trees — callers should set expectations. + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$DistroName, + [Parameter(Mandatory)][hashtable]$State + ) + $result = New-Object System.Collections.Generic.List[hashtable] + foreach ($s in (Get-Sessions -State $State)) { + if (-not ($s -is [hashtable])) { continue } + $type = Get-SessionType -Session $s + $project = [string]$s.project + $name = [string]$s.name + if ($type -eq 'host') { + $hostWt = if ($s.ContainsKey('hostWorktreePath')) { [string]$s.hostWorktreePath } else { '' } + if (-not $hostWt -or -not (Test-Path -LiteralPath $hostWt -PathType Container)) { continue } + foreach ($dirName in $Script:KnownArtifactDirs) { + $candidate = Join-Path $hostWt $dirName + if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { continue } + # Recursive size on Windows. Slow on giant node_modules trees + # (multi-second), but accurate and offline. + $bytes = 0L + try { + $sum = Get-ChildItem -LiteralPath $candidate -Recurse -File -Force -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum + if ($sum -and $sum.Sum) { $bytes = [long]$sum.Sum } + } catch {} + $result.Add(@{ + Project = $project + Session = $name + Type = 'host' + Path = $hostWt + ArtifactDir = $dirName + Bytes = $bytes + }) + } + } + else { + $wt = Get-SessionWorktreePath -Project $project -Name $name + # Single `du -sb` per candidate. Probe existence first so missing + # dirs don't produce noise on stderr. + foreach ($dirName in $Script:KnownArtifactDirs) { + # Plain string concat — Linux paths use '/'; Join-Path would + # emit '\' on a Windows host and break the in-distro shell. + $qDir = ConvertTo-BashQuoted "$wt/$dirName" + # `cut -f1` keeps just the byte count from `du -sb`'s two-column + # output. Plain pipe + cut sidesteps the variable-mangling + # concerns in gotchas #1 and #13 — no shell vars in flight. + $cmd = "[ -d $qDir ] && du -sb $qDir 2>/dev/null | cut -f1 || echo absent" + $r = Invoke-InDistro -Name $DistroName -User 'claude' -Command $cmd -AllowFail -CaptureOutput + if ($r.ExitCode -ne 0) { continue } + $line = $r.Output | Where-Object { $_ -is [string] -and ($_.Trim() -match '^\d+$' -or $_.Trim() -eq 'absent') } | Select-Object -Last 1 + if (-not $line) { continue } + $t = ([string]$line).Trim() + if ($t -eq 'absent') { continue } + $bytes = [long]$t + $result.Add(@{ + Project = $project + Session = $name + Type = 'distro' + Path = $wt + ArtifactDir = $dirName + Bytes = $bytes + }) + } + } + } + return $result.ToArray() +} + +Export-ModuleMember -Function ` + Format-Bytes, ` + Find-OrphanedSessions, ` + Find-StaleWorktrees, ` + Find-DanglingMounts, ` + Find-HeavyArtifacts diff --git a/tests/distro/Prune.Tests.ps1 b/tests/distro/Prune.Tests.ps1 new file mode 100644 index 0000000..7e25ede --- /dev/null +++ b/tests/distro/Prune.Tests.ps1 @@ -0,0 +1,91 @@ +# Prune.Tests.ps1 — end-to-end coverage for the `prune` verb against a real +# ephemeral distro. Builds a session, manually corrupts the state by deleting +# the worktree dir, then asserts that prune detects + repairs the drift. + +BeforeAll { + $repoRoot = if ($env:CLAUDEARIUM_REPO_ROOT) { $env:CLAUDEARIUM_REPO_ROOT } else { + Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + } + $distro = if ($env:CLAUDEARIUM_TEST_DISTRO) { $env:CLAUDEARIUM_TEST_DISTRO } else { 'claudearium-test' } + Import-Module (Join-Path $repoRoot 'modules\Wsl.psm1') -Force + Import-Module (Join-Path $repoRoot 'modules\State.psm1') -Force + Import-Module (Join-Path $repoRoot 'tests\lib\TestRunHelpers.psm1') -Force + $script:repoRoot = $repoRoot + $script:distro = $distro + $script:profilePath = New-IsolatedTestProfile -DistroName $distro -Tag 'prune' + + # Same in-distro bare repo pattern as Project.Tests.ps1. + Invoke-InDistroScript -Name $distro -User 'claude' -Script @' +set -e +rm -rf /tmp/prune-test-remote.git /tmp/prune-test-seed +git init --bare /tmp/prune-test-remote.git >/dev/null +git -C /tmp/prune-test-remote.git symbolic-ref HEAD refs/heads/master >/dev/null +mkdir /tmp/prune-test-seed && cd /tmp/prune-test-seed +git init -q -b master +git config user.email t@t && git config user.name t +echo hi > README.md +git add . && git commit -qm init +git push -q /tmp/prune-test-remote.git master +'@ + $script:remoteUrl = 'file:///tmp/prune-test-remote.git' + $script:proj = 'prunetest' + + Invoke-Claudearium -DistroName $script:distro -ProfilePath $script:profilePath -Args @{ + Verb='project'; SubVerb='add'; Arg=$script:proj + Remote=$script:remoteUrl; DefaultBranch='master' + } + Invoke-Claudearium -DistroName $script:distro -ProfilePath $script:profilePath -Args @{ + Verb='session'; SubVerb='new'; Arg='dev' + Project=$script:proj; Branch='master' + } +} + +AfterAll { + try { + Invoke-Claudearium -DistroName $script:distro -ProfilePath $script:profilePath ` + -Args @{ Verb='project'; SubVerb='remove'; Arg=$script:proj; Force=$true } -AllowFail | Out-Null + } catch {} + Invoke-InDistro -Name $script:distro -User 'claude' -AllowFail -CaptureOutput ` + -Command 'rm -rf /tmp/prune-test-remote.git /tmp/prune-test-seed' | Out-Null + Remove-Item -LiteralPath $script:profilePath -ErrorAction SilentlyContinue +} + +Describe 'prune detects orphaned sessions when the worktree dir is gone' -Tag 'distro' { + # Simulate drift: rm -rf the worktree directly, leaving state.sessions + # pointing at a path that no longer exists. + It 'reports the orphan in -DryRun without mutating state' { + Invoke-InDistro -Name $script:distro -User 'claude' ` + -Command "rm -rf /home/claude/projects/$($script:proj)/sessions/dev" -AllowFail | Out-Null + + $claudearium = Get-ClaudeariumScriptPath + $out = & $claudearium prune -Scope sessions -DryRun ` + -Name $script:distro -ProfilePath $script:profilePath -NonInteractive *>&1 + $txt = ($out -join "`n") + $txt | Should -Match 'orphan: ' + $txt | Should -Match ([regex]::Escape($script:proj)) + + # State must still have the session (we ran -DryRun). + $state = Read-State -DistroName $script:distro + @($state.sessions | Where-Object { $_.project -eq $script:proj -and $_.name -eq 'dev' }).Count | Should -Be 1 + } + + It 'removes the orphan record (and prunes the stale worktree ref) when run without -DryRun' { + # -Scope all here so the next test starts from a fully-clean distro; + # rm'ing the worktree dir leaves both a stale state.sessions record + # AND a `prunable` entry in `git worktree list`, so we need both + # scopes to converge. + $claudearium = Get-ClaudeariumScriptPath + & $claudearium prune -Scope all -Force ` + -Name $script:distro -ProfilePath $script:profilePath -NonInteractive *>&1 | Out-Null + + $state = Read-State -DistroName $script:distro + @($state.sessions | Where-Object { $_.project -eq $script:proj -and $_.name -eq 'dev' }).Count | Should -Be 0 + } + + It 'is a no-op on a clean distro (no scopes find drift)' { + $claudearium = Get-ClaudeariumScriptPath + $out = & $claudearium prune -Scope all ` + -Name $script:distro -ProfilePath $script:profilePath -NonInteractive *>&1 + ($out -join "`n") | Should -Match 'Nothing to prune' + } +} diff --git a/tests/lib/TestRegistry.psm1 b/tests/lib/TestRegistry.psm1 index a3c4168..a420765 100644 --- a/tests/lib/TestRegistry.psm1 +++ b/tests/lib/TestRegistry.psm1 @@ -179,6 +179,17 @@ $Script:Manifest = @( EstSeconds = 2 Description = 'Get-LocalVersion parse, Test-IsOurRepo permissive matching, update-check state round-trip + throttle math, Get-LatestReleaseInfo via mocked Invoke-RestMethod, manifest-diff helper' }, + @{ + Id = 'pure/Prune' + File = 'tests/pure/Prune.Tests.ps1' + Group = 'pure' + SubGroup = 'Prune' + Kind = 'auto' + NeedsDistro = $false + NeedsVpnReal = $false + EstSeconds = 2 + Description = 'Format-Bytes formatting; Find-DanglingMounts set-difference against mocked actual/desired; Find-OrphanedSessions detects missing host worktrees via Test-Path' + }, @{ Id = 'pure/Projects' File = 'tests/pure/Projects.Tests.ps1' @@ -322,6 +333,17 @@ $Script:Manifest = @( EstSeconds = 40 Description = 'VPN payload deploy + wg-config split-AllowedIPs transform + killswitch ruleset behaviorally blocks egress to public IPs (no systemctl)' }, + @{ + Id = 'distro/Prune' + File = 'tests/distro/Prune.Tests.ps1' + Group = 'distro' + SubGroup = 'Prune' + Kind = 'auto' + NeedsDistro = $true + NeedsVpnReal = $false + EstSeconds = 25 + Description = 'prune -Scope sessions detects + repairs orphaned state.sessions entries after a manual worktree wipe; -DryRun does not mutate state; -Scope all on a clean distro prints "Nothing to prune"' + }, @{ Id = 'distro/Reconcile' File = 'tests/distro/Reconcile.Tests.ps1' diff --git a/tests/pure/Prune.Tests.ps1 b/tests/pure/Prune.Tests.ps1 new file mode 100644 index 0000000..dad22b2 --- /dev/null +++ b/tests/pure/Prune.Tests.ps1 @@ -0,0 +1,112 @@ +# Prune.Tests.ps1 — pure tests for modules/Prune.psm1. Anything that needs a +# real distro or a real git checkout lives under tests/distro/Prune.Tests.ps1. + +BeforeAll { + $repoRoot = if ($env:CLAUDEARIUM_REPO_ROOT) { + $env:CLAUDEARIUM_REPO_ROOT + } else { + Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + } + Import-Module (Join-Path $repoRoot 'modules\Prune.psm1') -Force +} + +Describe 'Format-Bytes' { + It 'returns plain bytes under 1KB' { + Format-Bytes -Bytes 0 | Should -Be '0 B' + Format-Bytes -Bytes 512 | Should -Be '512 B' + Format-Bytes -Bytes 1023 | Should -Be '1023 B' + } + It 'uses K for >=1KB and <1MB' { + Format-Bytes -Bytes 1024 | Should -Be '1.0K' + Format-Bytes -Bytes (50KB) | Should -Be '50.0K' + } + It 'uses M for >=1MB and <1GB' { + Format-Bytes -Bytes (1MB) | Should -Be '1.0M' + Format-Bytes -Bytes (250MB) | Should -Be '250.0M' + } + It 'uses G for >=1GB' { + Format-Bytes -Bytes (1GB) | Should -Be '1.0G' + Format-Bytes -Bytes (2500MB) | Should -Be '2.4G' + } +} + +Describe 'Find-DanglingMounts' { + # Pure-ish: mocks the in-distro fstab read and the merged-desired computer. + It 'returns mounts present in actual but missing from desired' { + Mock -ModuleName Prune Get-HostMountsActualFromDistro { + ,@( + @{ guest = '/host/keep'; host = 'C:\keep' } + @{ guest = '/host/dangling'; host = 'C:\old' } + ) + } + Mock -ModuleName Prune Get-MergedDesiredMounts { + ,@( @{ guest = '/host/keep'; host = 'C:\keep' } ) + } + # @(...) wrap: Find-DanglingMounts returns the raw ToArray() result. + # A single-element result unwraps to a bare hashtable at the call + # site, so wrap unconditionally to get reliable indexing + Count. + $r = @(Find-DanglingMounts -DistroName 'd' -State @{} -ProfileSpec @{}) + $r.Count | Should -Be 1 + $r[0].Guest | Should -Be '/host/dangling' + } + + It 'returns an empty array when actual is a subset of desired' { + Mock -ModuleName Prune Get-HostMountsActualFromDistro { + ,@( @{ guest = '/host/a'; host = 'C:\a' } ) + } + Mock -ModuleName Prune Get-MergedDesiredMounts { + ,@( + @{ guest = '/host/a'; host = 'C:\a' } + @{ guest = '/host/b'; host = 'C:\b' } + ) + } + @(Find-DanglingMounts -DistroName 'd' -State @{} -ProfileSpec @{}).Count | Should -Be 0 + } +} + +Describe 'Find-OrphanedSessions (host-side check)' { + # Pure for host sessions because Test-Path is the only filesystem touch + # and we can point it at a path we know doesn't exist. Distro sessions + # need the in-distro test path which lives under tests/distro/. + It 'reports a host session whose hostWorktreePath has been deleted' { + $missingPath = Join-Path ([System.IO.Path]::GetTempPath()) ("prune-test-missing-" + [Guid]::NewGuid().ToString('N')) + $state = @{ + sessions = @(@{ + project = 'p1' + name = 's1' + type = 'host' + hostWorktreePath = $missingPath + worktreePath = '/host/p1/s1' + }) + } + # The function still consults the distro for any distro-type sessions + # in the list — none here, so Invoke-InDistro is never called. Mock + # to fail loudly if that assumption breaks. + Mock -ModuleName Prune Invoke-InDistro { throw 'unexpected distro probe' } + $r = @(Find-OrphanedSessions -State $state -DistroName 'd') + $r.Count | Should -Be 1 + $r[0].Project | Should -Be 'p1' + $r[0].Name | Should -Be 's1' + $r[0].Type | Should -Be 'host' + } + + It 'does NOT report a host session whose hostWorktreePath still exists' { + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("prune-test-present-" + [Guid]::NewGuid().ToString('N')) + [void][System.IO.Directory]::CreateDirectory($tempDir) + try { + $state = @{ + sessions = @(@{ + project = 'p1' + name = 's1' + type = 'host' + hostWorktreePath = $tempDir + worktreePath = '/host/p1/s1' + }) + } + Mock -ModuleName Prune Invoke-InDistro { throw 'unexpected distro probe' } + @(Find-OrphanedSessions -State $state -DistroName 'd').Count | Should -Be 0 + } finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +}