add prune verb for drift detection and repair#25
Merged
Conversation
New `claudearium prune [-Scope <area>] [-DryRun] [-Force]`. Four scopes:
sessions — state.sessions entries whose worktree dir is gone
worktrees — `git worktree list` entries with `prunable` set or path missing
mounts — fstab managed-block entries with no matching host session
artifacts — heavy untracked build dirs (node_modules/target/.next/dist/
build/out/obj/bin) >= 5 MB inside live session worktrees
-DryRun reports drift and exits without mutating anything. -Force skips
the per-directory prompt in the `artifacts` scope. -Scope all (default)
runs every scope.
- modules/Prune.psm1 (new): Find-OrphanedSessions, Find-StaleWorktrees,
Find-DanglingMounts, Find-HeavyArtifacts, Format-Bytes. Each Find-*
branches on emptiness to return `@()` rather than `,@(empty)` (the
latter is a 1-element array containing the empty array — breaks
`@(...).Count` checks).
- claudearium.ps1: Invoke-Prune dispatches by scope. Persists state
between the sessions/mounts scopes so the merged-mount rebuild sees
the just-pruned sessions. New -Scope and -DryRun script params; the
existing -Force is reused for the artifact prompt skip.
- Tests: pure (Format-Bytes formatting, Find-DanglingMounts set diff
via mocks, Find-OrphanedSessions Test-Path probe — 8 cases) + distro
(add project + session, manually rm -rf the worktree, assert prune
detects + repairs, -DryRun preserves state, clean distro shows
"Nothing to prune" — 3 cases). Pure: 366/366 locally.
- Docs: usage.md verb table, cookbook.md recipe, architecture.md
module list + verb table, testing.md test counts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new prune verb to the claudearium CLI that detects (and optionally repairs) drift between state.json, the profile, and the distro filesystem. Drift detectors live in a new modules/Prune.psm1 module and cover four scopes (sessions, worktrees, mounts, artifacts) plus an all aggregator. -DryRun reports without mutating; -Force suppresses per-dir prompts in the destructive artifacts scope. Tests, registry entries, and docs/cookbook/architecture/testing pages are updated accordingly.
Changes:
- New
modules/Prune.psm1exportingFormat-Bytes,Find-OrphanedSessions,Find-StaleWorktrees,Find-DanglingMounts,Find-HeavyArtifacts. - New
Invoke-Pruneverb inclaudearium.ps1with-Scope/-DryRun/-Forceparams, wired into the verb dispatch and help text. - Pure + distro Pester suites for the new module, registered in
TestRegistry.psm1; usage/cookbook/architecture/testing docs extended to cover the verb.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| modules/Prune.psm1 | New module: drift-detection primitives + size formatter. |
| claudearium.ps1 | New -Scope/-DryRun params, Invoke-Prune orchestrator, verb dispatch + help text. |
| tests/pure/Prune.Tests.ps1 | Pure unit tests for Format-Bytes, Find-DanglingMounts, Find-OrphanedSessions (host side). |
| tests/distro/Prune.Tests.ps1 | End-to-end coverage: simulate orphan, exercise -DryRun, apply, and clean-distro no-op. |
| tests/lib/TestRegistry.psm1 | Registers the new pure and distro test entries. |
| docs/usage.md | Documents the new verb, scopes, and flags. |
| docs/cookbook.md | Adds a "reclaim disk and resync" recipe. |
| docs/architecture.md | Adds the new module to the tree and the new verb to the declarative-verbs row. |
| docs/testing.md | Refreshes headline test counts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+81
to
+86
| 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' | ||
| } |
Comment on lines
+41
to
+43
| 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)) |
| $stateMutated = $false | ||
|
|
||
| Write-Host '' | ||
| Write-Host "=== Claudearium prune (scope: $scope$(if ($DryRun) { ', dry-run' }))===" -ForegroundColor Cyan |
Comment on lines
+266
to
+338
| 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 | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| if ($result.Count -eq 0) { return @() } | ||
| return ,@($result.ToArray()) | ||
| } |
Comment on lines
+1144
to
+1166
| # ---- sessions scope ---- | ||
| if (& $runScope 'sessions') { | ||
| Write-Host '' | ||
| Write-Host '[sessions] Looking for state.sessions records whose worktree is gone ...' | ||
| $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 | ||
| } | ||
| } | ||
| } |
Comment on lines
+126
to
+180
| 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' } | ||
| }) | ||
| } | ||
| } | ||
| } |
When a Find-* helper returns @() (empty), PowerShell unwraps it to $null at the caller's assignment boundary. The subsequent `.Count` access then throws under StrictMode: "The property 'Count' cannot be found on this object." The fix is `$x = @(Find-* ...)`, which forces an empty Object[] through the wrap even when the pipeline produces nothing. Caught by the distro lane: the no-op test on a clean distro ran prune through the artifacts-scope code path where Find-HeavyArtifacts returned no rows and the subsequent `Where-Object` pipeline was operating on the unwrapped $null. Also adjusts the distro round-trip: the second test now runs `-Scope all -Force` so the third test starts from a fully-clean distro. Previously `-Scope sessions` left a stale `git worktree list` entry that the third test would (correctly) flag, making "Nothing to prune" fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`,@(array)` returned from a function survives the caller's `@()` wrap by getting NESTED — the wrap doesn't deep-flatten, so iteration on the result yields a single inner-array element rather than the hashtables. That broke the worktrees-scope teardown: `$byLocation[<arr>].Add(<arr>)` threw "Cannot find an overload for Add and argument count 1" because the supposedly-flat session entry came through as an Object[] wrapper. The fix: Find-* helpers just return `$list.ToArray()`. Callers always `@(...)` wrap — that pattern survives both the empty case (PowerShell unwraps `@()` to nothing, `@(nothing)` re-arrays it to a 0-Count Object[]) and the populated case (the wrap preserves array shape on a single-element result without nesting it). Pure tests updated to match the new caller-wrap discipline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines
+41
to
+43
| 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)) |
| throw "Distro '$distro' does not exist; nothing to prune." | ||
| } | ||
| $spec = $null | ||
| try { $spec = Read-ProfileIfPresent } catch { } |
| } | ||
| foreach ($projName in $mirrorNames) { | ||
| $qPath = ConvertTo-BashQuoted "/home/claude/mirrors/$projName.git" | ||
| $wtCmd = "git -C $qPath worktree list --porcelain 2>/dev/null || true" |
| $stateMutated = $false | ||
|
|
||
| Write-Host '' | ||
| Write-Host "=== Claudearium prune (scope: $scope$(if ($DryRun) { ', dry-run' }))===" -ForegroundColor Cyan |
Comment on lines
+280
to
+335
| 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() | ||
| } |
Comment on lines
+92
to
+96
| # 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() |
Comment on lines
+205
to
+232
| $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) } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
New verb
claudearium prune [-Scope <area>] [-DryRun] [-Force]. Detects drift betweenstate.json, profile, and the distro filesystem, and repairs it on demand. Four scopes:sessions— state.sessions entries whose worktree dir is gone → drop recordworktrees—git worktree listentries withprunableset or pointing at a missing dir →git worktree prunemounts— fstab managed-block entries with no matching host session → rebuild viaInvoke-MergedMountsApplyartifacts— heavy untracked build dirs (node_modules,target,.next,dist,build,out,obj,bin) >= 5 MB inside live session worktrees → promptedrm -rf-DryRunreports drift and exits without mutating anything.-Forceskips the per-dir prompt in theartifactsscope.Test plan
.\test-claudearium.ps1 -ParseCheck(71 files clean).\test-claudearium.ps1 -Auto -Only pure -CI(366/366, +8 new)tests/distro/Prune.Tests.ps1covers the orphan-detect + repair round-tripprune -DryRunagainst a clean distro (Nothing to prune); intentionallyrm -rfa session worktree, re-run, confirm orphan reported🤖 Generated with Claude Code