public/Invoke-WtwClean.ps1

function Invoke-WtwClean {
    <#
    .SYNOPSIS
        Find and remove stale AI worktrees and/or local branches already merged.
    .DESCRIPTION
        Two independent sweeps:

          --worktrees stale AI folders (codex / cursor / conductor) and
                        detached-HEAD worktrees on registered repos
          --branches local branches fully merged into the repo default
                        branch, skipping any branch still checked out
          --all both

        With none of those flags, asks which sweep to run. Item pick
        (all / none / 1,3,5) still applies unless ``--force``.
    .PARAMETER All
        Run both sweeps.
    .PARAMETER Worktrees
        Only the stale-worktree sweep.
    .PARAMETER Branches
        Only the merged-branch sweep.
    .PARAMETER DryRun
        Preview without removing anything.
    .PARAMETER Force
        Remove every listed item without the all/none/1,3,5 picker.
    .EXAMPLE
        wtw clean
        Ask worktrees / branches / all, then pick items.
    .EXAMPLE
        wtw clean --branches --dry-run
        List leftover merged local branches.
    .EXAMPLE
        wtw clean --all --force
        Remove every stale worktree and every leftover merged branch.
    #>

    [CmdletBinding()]
    param(
        [switch] $All,
        [switch] $Worktrees,
        [switch] $Branches,
        [switch] $DryRun,
        [switch] $Force
    )

    $config = Get-WtwConfig
    if (-not $config) {
        Write-Error 'wtw not initialized. Run "wtw init" first.'
        return
    }

    Write-Host ''
    $scope = Resolve-WtwCleanScope -All:$All -Worktrees:$Worktrees -Branches:$Branches
    if (-not $scope) { return }

    $registry = Get-WtwRegistry
    $didWork = $false

    if ($scope.Worktrees) {
        $didWork = $true
        Invoke-WtwCleanWorktrees -Config $config -Registry $registry -DryRun:$DryRun -Force:$Force
    }
    if ($scope.Branches) {
        $didWork = $true
        Invoke-WtwCleanMergedBranches -Registry $registry -DryRun:$DryRun -Force:$Force
    }
    if (-not $didWork) {
        Write-Host ' Nothing selected.' -ForegroundColor DarkGray
    }
}

function Invoke-WtwCleanWorktrees {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Config,
        [Parameter(Mandatory)] $Registry,
        [switch] $DryRun,
        [switch] $Force
    )

    Write-Host ' Scanning for stale worktrees...' -ForegroundColor Cyan

    $supersetGuard = @{}
    if (Get-Command superset -ErrorAction SilentlyContinue) {
        $projJson = & superset projects list --json 2>$null
        $projExit = $LASTEXITCODE
        $wsJson = & superset workspaces list --json 2>$null
        $wsExit = $LASTEXITCODE
        if ($projExit -eq 0 -and $wsExit -eq 0 -and $projJson -and $wsJson) {
            try {
                $projs = $projJson | ConvertFrom-Json
                $ws = $wsJson | ConvertFrom-Json
                $projById = @{}
                foreach ($p in $projs) { $projById[$p.id] = $p }
                foreach ($w in $ws) {
                    $p = $projById[$w.projectId]
                    if (-not $p) { continue }
                    foreach ($key in @($p.slug, $p.id)) {
                        if (-not $key) { continue }
                        $supersetGuard[(Join-Path $HOME ".superset/worktrees/$key/$($w.name)")] = $true
                        $supersetGuard[(Join-Path $HOME ".superset/worktrees/$key/$($w.branch)")] = $true
                    }
                }
            } catch { }
        }
    }

    $staleItems = @()
    $stalePaths = @()
    if ((Get-WtwPropertyNames -Object $Config) -contains 'staleWorktreePaths' -and $Config.staleWorktreePaths) {
        $stalePaths = @($Config.staleWorktreePaths)
    }

    foreach ($stalePath in $stalePaths) {
        $resolvedPath = $stalePath.Replace('~', $HOME)
        $resolvedPath = [System.IO.Path]::GetFullPath($resolvedPath)

        if (-not (Test-Path $resolvedPath)) { continue }

        $toolName = Split-Path (Split-Path $resolvedPath -Parent) -Leaf
        if ($toolName -eq $HOME) { $toolName = Split-Path $resolvedPath -Leaf }

        $dirs = Get-ChildItem -Path $resolvedPath -Directory -ErrorAction SilentlyContinue
        foreach ($dir in $dirs) {
            $repoDirs = Get-ChildItem -Path $dir.FullName -Directory -ErrorAction SilentlyContinue
            if ($repoDirs) {
                foreach ($repoDir in $repoDirs) {
                    if ($supersetGuard.ContainsKey($repoDir.FullName)) { continue }
                    $gitFile = Join-Path $repoDir.FullName '.git'
                    if (Test-Path $gitFile) {
                        $size = Get-DirectorySize $repoDir.FullName
                        $staleItems += [PSCustomObject]@{
                            Source   = $toolName
                            Path     = $repoDir.FullName
                            Repo     = $repoDir.Name
                            Size     = $size
                            SizeStr  = Format-Size $size
                            Modified = $repoDir.LastWriteTime.ToString('yyyy-MM-dd')
                            Type     = 'ai-worktree'
                        }
                    }
                }
            } else {
                if ($supersetGuard.ContainsKey($dir.FullName)) { continue }
                $gitFile = Join-Path $dir.FullName '.git'
                if (Test-Path $gitFile) {
                    $size = Get-DirectorySize $dir.FullName
                    $staleItems += [PSCustomObject]@{
                        Source   = $toolName
                        Path     = $dir.FullName
                        Repo     = $dir.Name
                        Size     = $size
                        SizeStr  = Format-Size $size
                        Modified = $dir.LastWriteTime.ToString('yyyy-MM-dd')
                        Type     = 'ai-worktree'
                    }
                }
            }
        }
    }

    foreach ($repoName in (Get-WtwPropertyNames -Object $Registry.repos)) {
        $repo = $Registry.repos.$repoName
        if (-not (Test-Path $repo.mainPath)) { continue }

        $wtList = git -C $repo.mainPath worktree list --porcelain 2>$null
        if (-not $wtList) { continue }

        $currentWt = $null
        foreach ($line in $wtList) {
            if ($line -match '^worktree (.+)$') {
                $currentWt = @{ path = $Matches[1] }
            } elseif ($line -match '^HEAD (.+)$' -and $currentWt) {
                $currentWt.head = $Matches[1]
            } elseif ($line -eq 'detached' -and $currentWt) {
                if ($currentWt.path -ne $repo.mainPath) {
                    $alreadyListed = $staleItems | Where-Object { $_.Path -eq $currentWt.path }
                    if (-not $alreadyListed -and (Test-Path $currentWt.path)) {
                        $dir = Get-Item $currentWt.path
                        $size = Get-DirectorySize $currentWt.path
                        $staleItems += [PSCustomObject]@{
                            Source   = 'git'
                            Path     = $currentWt.path
                            Repo     = $repoName
                            Size     = $size
                            SizeStr  = Format-Size $size
                            Modified = $dir.LastWriteTime.ToString('yyyy-MM-dd')
                            Type     = 'detached'
                        }
                    }
                }
            } elseif ($line -eq '' -and $currentWt) {
                $currentWt = $null
            }
        }
    }

    if ($staleItems.Count -eq 0) {
        Write-Host ' No stale worktrees found.' -ForegroundColor Green
        return
    }

    $staleItems = @($staleItems | Sort-Object -Property Size -Descending)
    $totalSize = ($staleItems | Measure-Object -Property Size -Sum).Sum

    Write-Host ''
    Write-Host " Found $($staleItems.Count) stale worktrees ($(Format-Size $totalSize) total)" -ForegroundColor Yellow
    Write-Host ''
    Format-WtwTable $staleItems @('Source', 'Repo', 'SizeStr', 'Modified', 'Path')
    Write-Host ''

    if ($DryRun) {
        Write-Host ' (dry-run: no changes made)' -ForegroundColor DarkGray
        return
    }

    $staleItems = Select-WtwCleanItems -Items $staleItems -Force:$Force -Noun 'stale worktrees'
    if ($null -eq $staleItems) { return }

    $removedSize = 0
    $removedCount = 0

    foreach ($item in $staleItems) {
        Write-Host " Removing: $($item.Path)..." -ForegroundColor Cyan -NoNewline

        try {
            $parentRepo = $null
            foreach ($rn in (Get-WtwPropertyNames -Object $Registry.repos)) {
                $r = $Registry.repos.$rn
                if ($item.Path.StartsWith($r.mainPath) -or $item.Repo -eq (Split-Path $r.mainPath -Leaf)) {
                    $parentRepo = $r.mainPath
                    break
                }
            }

            if ($parentRepo -and (Test-Path $parentRepo)) {
                git -C $parentRepo worktree remove $item.Path --force 2>$null
            }

            if (Test-Path $item.Path) {
                Remove-Item -Path $item.Path -Recurse -Force
            }

            $removedSize += $item.Size
            $removedCount++
            Write-Host ' done' -ForegroundColor Green
        } catch {
            Write-Host " FAILED: $_" -ForegroundColor Red
        }
    }

    foreach ($repoName in (Get-WtwPropertyNames -Object $Registry.repos)) {
        $repo = $Registry.repos.$repoName
        if (Test-Path $repo.mainPath) {
            git -C $repo.mainPath worktree prune 2>$null
        }
    }

    Write-Host ''
    Write-Host " Removed $removedCount worktrees, freed $(Format-Size $removedSize)" -ForegroundColor Green
}

function Invoke-WtwCleanMergedBranches {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Registry,
        [switch] $DryRun,
        [switch] $Force
    )

    Write-Host ' Scanning for merged local branches...' -ForegroundColor Cyan

    $items = @()
    $skipped = @()
    foreach ($repoName in (Get-WtwPropertyNames -Object $Registry.repos)) {
        $repo = $Registry.repos.$repoName
        $mainPath = Get-WtwPropertyValue -Object $repo -Name 'mainPath'
        if (-not $mainPath -or -not (Test-Path $mainPath)) { continue }

        $found = Get-WtwMergedLocalBranches -RepoPath $mainPath -RepoName $repoName
        if ($found.Items) { $items += @($found.Items) }
        foreach ($name in @($found.Skipped)) {
            $skipped += "$repoName/$name"
        }
    }

    if ($items.Count -eq 0) {
        Write-Host ' No leftover merged branches found.' -ForegroundColor Green
        if ($skipped.Count -gt 0) {
            Write-Host " Still checked out in a worktree (use wtw remove): $($skipped -join ', ')" -ForegroundColor DarkGray
        }
        return
    }

    Write-Host ''
    Write-Host " Found $($items.Count) merged local branch(es)" -ForegroundColor Yellow
    Write-Host ''
    Format-WtwTable $items @('Repo', 'Branch', 'Into', 'Status')
    Write-Host ''
    if ($skipped.Count -gt 0) {
        Write-Host " Skipped (still in a worktree): $($skipped -join ', ')" -ForegroundColor DarkGray
        Write-Host ''
    }

    if ($DryRun) {
        Write-Host ' (dry-run: no changes made)' -ForegroundColor DarkGray
        return
    }

    $items = Select-WtwCleanItems -Items $items -Force:$Force -Noun 'merged branches'
    if ($null -eq $items) { return }

    $removed = 0
    foreach ($item in $items) {
        $repo = $Registry.repos.($item.Repo)
        $mainPath = Get-WtwPropertyValue -Object $repo -Name 'mainPath'
        Write-Host " Deleting $($item.Repo)/$($item.Branch)..." -ForegroundColor Cyan -NoNewline
        git -C $mainPath branch -d $item.Branch 2>$null
        if ($LASTEXITCODE -eq 0) {
            $removed++
            Write-Host ' done' -ForegroundColor Green
        } else {
            Write-Host ' FAILED (not fully merged, or still checked out)' -ForegroundColor Red
        }
    }

    Write-Host ''
    Write-Host " Deleted $removed merged branch(es)." -ForegroundColor Green
}

function Get-DirectorySize {
    param([string] $Path)
    try {
        if (-not $IsWindows) {
            $duOutput = du -sk $Path 2>$null
            if ($duOutput -match '^\s*(\d+)') {
                return [long]$Matches[1] * 1024
            }
        }
        $bytes = (Get-ChildItem -Path $Path -Recurse -File -Force -ErrorAction SilentlyContinue |
            Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
        return [long]($bytes ?? 0)
    } catch {
        return 0
    }
}

function Format-Size {
    param([long] $Bytes)
    if ($Bytes -ge 1GB) { return '{0:N1} GB' -f ($Bytes / 1GB) }
    if ($Bytes -ge 1MB) { return '{0:N0} MB' -f ($Bytes / 1MB) }
    if ($Bytes -ge 1KB) { return '{0:N0} KB' -f ($Bytes / 1KB) }
    return "$Bytes B"
}