Skip to content

add prune verb for drift detection and repair#25

Merged
MaceWindu merged 3 commits into
masterfrom
feat/prune-verb
May 18, 2026
Merged

add prune verb for drift detection and repair#25
MaceWindu merged 3 commits into
masterfrom
feat/prune-verb

Conversation

@MaceWindu
Copy link
Copy Markdown
Owner

Summary

New verb claudearium prune [-Scope <area>] [-DryRun] [-Force]. Detects drift between state.json, profile, and the distro filesystem, and repairs it on demand. Four scopes:

  • sessions — state.sessions entries whose worktree dir is gone → drop record
  • worktreesgit worktree list entries with prunable set or pointing at a missing dir → git worktree prune
  • mounts — fstab managed-block entries with no matching host session → rebuild via Invoke-MergedMountsApply
  • artifacts — heavy untracked build dirs (node_modules, target, .next, dist, build, out, obj, bin) >= 5 MB inside live session worktrees → prompted rm -rf

-DryRun reports drift and exits without mutating anything. -Force skips the per-dir prompt in the artifacts scope.

Test plan

  • .\test-claudearium.ps1 -ParseCheck (71 files clean)
  • .\test-claudearium.ps1 -Auto -Only pure -CI (366/366, +8 new)
  • Distro lane via CI — new tests/distro/Prune.Tests.ps1 covers the orphan-detect + repair round-trip
  • Manual: prune -DryRun against a clean distro (Nothing to prune); intentionally rm -rf a session worktree, re-run, confirm orphan reported

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings May 18, 2026 00:03
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.psm1 exporting Format-Bytes, Find-OrphanedSessions, Find-StaleWorktrees, Find-DanglingMounts, Find-HeavyArtifacts.
  • New Invoke-Prune verb in claudearium.ps1 with -Scope/-DryRun/-Force params, 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 thread modules/Prune.psm1
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))
Comment thread claudearium.ps1
$stateMutated = $false

Write-Host ''
Write-Host "=== Claudearium prune (scope: $scope$(if ($DryRun) { ', dry-run' }))===" -ForegroundColor Cyan
Comment thread modules/Prune.psm1
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 thread claudearium.ps1
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 thread modules/Prune.psm1
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' }
})
}
}
}
MaceWindu and others added 2 commits May 18, 2026 02:25
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>
Copilot AI review requested due to automatic review settings May 18, 2026 00:35
@MaceWindu MaceWindu merged commit 46c5fda into master May 18, 2026
9 checks passed
@MaceWindu MaceWindu deleted the feat/prune-verb branch May 18, 2026 00:38
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Comment thread modules/Prune.psm1
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))
Comment thread claudearium.ps1
throw "Distro '$distro' does not exist; nothing to prune."
}
$spec = $null
try { $spec = Read-ProfileIfPresent } catch { }
Comment thread modules/Prune.psm1
}
foreach ($projName in $mirrorNames) {
$qPath = ConvertTo-BashQuoted "/home/claude/mirrors/$projName.git"
$wtCmd = "git -C $qPath worktree list --porcelain 2>/dev/null || true"
Comment thread claudearium.ps1
$stateMutated = $false

Write-Host ''
Write-Host "=== Claudearium prune (scope: $scope$(if ($DryRun) { ', dry-run' }))===" -ForegroundColor Cyan
Comment thread modules/Prune.psm1
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 thread modules/Prune.psm1
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 thread modules/Prune.psm1
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) }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants