Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions claudearium.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ param(
[string]$HostCheckout,
[string]$To,
[switch]$DiscardDirty,
[string]$Scope,
[switch]$DryRun,
[string]$HostPath,
[string]$Guest,
[string]$Mode,
Expand Down Expand Up @@ -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
Expand All @@ -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 <a>] Drift detection + repair. -Scope sessions|worktrees|
mounts|artifacts|all. -DryRun to report only.

profile validate <path> Validate a profile (or the default profile if omitted).
profile export -Out <p> Write current state to a profile file at <p>.
Expand All @@ -123,6 +128,11 @@ Verbs:
project list Table of projects (profile + materialization status).
project remove <name> Delete bare mirror (or per-project bin dir for hostProjects),
sessions, and profile entry.
project move <name> Migrate a project between distro and host types in place.
-To host -HostCheckout <p>: distro -> host.
-To distro [-Remote <url>]: host -> distro (auto-detects
Remote from the existing hostCheckout's origin).
Refuses dirty sessions unless -DiscardDirty / -Force.
project show <name> Inspect a project's profile entry + mirror status.

session Bare = interactive dashboard.
Expand Down Expand Up @@ -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 <name> 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
}
}
}
Comment on lines +1144 to +1170

# ---- 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 ''
Expand Down Expand Up @@ -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 }
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
23 changes: 23 additions & 0 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 14 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<stamp>`) is written next to the profile before any mutation, for hand recovery.

## `prune [-Scope <area>] [-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 <n>` 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 <subverb?>`
Expand Down
Loading
Loading