public/Invoke-WtwClean.ps1

function Invoke-WtwClean {
    <#
    .SYNOPSIS
        Find and remove stale AI-created worktrees.
    .DESCRIPTION
        Scans configured stale worktree paths (codex, cursor, conductor) and registered
        repos for detached HEAD worktrees. Shows sizes and allows interactive selection
        of which items to remove. Prunes git worktree metadata after removal.
    .PARAMETER DryRun
        Preview stale worktrees without removing anything.
    .PARAMETER Force
        Remove all stale worktrees without interactive prompting.
    .EXAMPLE
        wtw clean --dry-run
        List all stale worktrees with sizes but make no changes.
    .EXAMPLE
        wtw clean --force
        Remove all stale worktrees without prompting.
    #>

    [CmdletBinding()]
    param(
        [switch] $DryRun,
        [switch] $Force
    )

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

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

    $staleItems = @()

    # 1. Scan stale worktree paths (AI tools)
    foreach ($stalePath in $config.staleWorktreePaths) {
        $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 }

        # Find all repo-like directories under the stale path
        $dirs = Get-ChildItem -Path $resolvedPath -Directory -ErrorAction SilentlyContinue
        foreach ($dir in $dirs) {
            # Look for repo directories inside (e.g., .codex/worktrees/3cc3/myrepo/)
            $repoDirs = Get-ChildItem -Path $dir.FullName -Directory -ErrorAction SilentlyContinue
            if ($repoDirs) {
                foreach ($repoDir in $repoDirs) {
                    $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 {
                # Might be a flat worktree dir
                $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'
                    }
                }
            }
        }
    }

    # 2. Scan registered repos for detached HEAD worktrees
    $registry = Get-WtwRegistry
    foreach ($repoName in $registry.repos.PSObject.Properties.Name) {
        $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) {
                # Skip main repo
                if ($currentWt.path -ne $repo.mainPath) {
                    # Skip if already in our stale list
                    $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
    }

    # Sort by size descending
    $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
    }

    # Interactive selection
    if (-not $Force) {
        Write-Host ' Options:' -ForegroundColor Yellow
        Write-Host ' all - Remove all stale worktrees'
        Write-Host ' none - Cancel'
        Write-Host ' 1,3,5 - Remove specific items (by number)'
        Write-Host ''
        $selection = Read-Host ' Select'

        if ($selection -eq 'none' -or -not $selection) {
            Write-Host ' Cancelled.' -ForegroundColor DarkGray
            return
        }

        if ($selection -ne 'all') {
            $indices = $selection -split '[,\s]+' | ForEach-Object { [int]$_ - 1 }
            $staleItems = $indices | ForEach-Object { $staleItems[$_] } | Where-Object { $_ }
        }
    }

    # Remove selected items
    $removedSize = 0
    $removedCount = 0

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

        try {
            # Try git worktree remove first
            $parentRepo = $null
            foreach ($rn in $registry.repos.PSObject.Properties.Name) {
                $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 still exists, force remove
            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
        }
    }

    # Prune all registered repos
    foreach ($repoName in $registry.repos.PSObject.Properties.Name) {
        $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 Get-DirectorySize {
    param([string] $Path)
    try {
        if (-not $IsWindows) {
            # du -sk is orders of magnitude faster than Get-ChildItem recursion
            $duOutput = du -sk $Path 2>$null
            if ($duOutput -match '^\s*(\d+)') {
                return [long]$Matches[1] * 1024
            }
        }
        # Fallback for Windows
        $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"
}