public/Invoke-WtwClean.ps1

function Invoke-WtwClean {
    <#
    .SYNOPSIS
        Find and remove stale AI worktrees, extra git worktrees, and/or merged branches.
    .DESCRIPTION
        Independent sweeps:

          --worktrees stale AI folders (codex / cursor / conductor),
                        detached-HEAD worktrees, and extra git worktrees
                        that are not registered in wtw
          --linked extra git worktrees on registered repos, including
                        ones wtw already tracks (alias: --extra)
          --branches local branches fully merged into the repo default
                        branch, skipping any branch still checked out
          --all worktrees + branches (not --linked)

        With none of those flags, asks which sweep to run. Item pick
        (all / none / 1,3,5) still applies unless ``--force``.
    .PARAMETER All
        Run the worktrees and branches sweeps.
    .PARAMETER Worktrees
        Only the stale / unregistered-worktree sweep.
    .PARAMETER Linked
        Only extra git worktrees (registered and unregistered).
    .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 / linked / branches / all, then pick items.
    .EXAMPLE
        wtw clean --linked --dry-run
        List extra git worktrees Fork / git worktree add created.
    .EXAMPLE
        wtw clean --branches --dry-run
        List leftover merged local branches.
    .EXAMPLE
        wtw clean --all --force
        Remove every stale / unregistered worktree and every leftover merged branch.
    #>

    [CmdletBinding()]
    param(
        [switch] $All,
        [switch] $Worktrees,
        [Alias('Extra')]
        [switch] $Linked,
        [switch] $Branches,
        [switch] $DryRun,
        [switch] $Force
    )

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

    Write-WtwHost ''
    $scope = Resolve-WtwCleanScope -All:$All -Worktrees:$Worktrees -Branches:$Branches -Linked:$Linked
    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.Linked) {
        $didWork = $true
        Invoke-WtwCleanLinkedWorktrees -Registry $registry -DryRun:$DryRun -Force:$Force
    }
    if ($scope.Branches) {
        $didWork = $true
        Invoke-WtwCleanMergedBranches -Registry $registry -DryRun:$DryRun -Force:$Force
    }
    if (-not $didWork) {
        Write-WtwHost ' Nothing selected.' -ForegroundColor DarkGray
    }
}

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

    Write-WtwHost ' 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
                            Branch   = '-'
                            Size     = $size
                            SizeStr  = Format-Size $size
                            Modified = $repoDir.LastWriteTime.ToString('yyyy-MM-dd')
                            Type     = 'ai-worktree'
                            MainPath = $null
                            Status   = 'unregistered'
                            Task     = $null
                        }
                    }
                }
            } 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
                        Branch   = '-'
                        Size     = $size
                        SizeStr  = Format-Size $size
                        Modified = $dir.LastWriteTime.ToString('yyyy-MM-dd')
                        Type     = 'ai-worktree'
                        MainPath = $null
                        Status   = 'unregistered'
                        Task     = $null
                    }
                }
            }
        }
    }

    $gitExtras = @(Get-WtwLinkedWorktreeItems -Registry $Registry -IncludeUnregistered -IncludeDetached)
    foreach ($extra in $gitExtras) {
        $alreadyListed = $staleItems | Where-Object { $_.Path -eq $extra.Path }
        if ($alreadyListed) { continue }
        $staleItems += $extra
    }

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

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

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

    if ($DryRun) {
        Write-WtwHost ' (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-WtwHost " Removing: $($item.Path)..." -ForegroundColor Cyan -NoNewline

        try {
            Remove-WtwCleanWorktreeItem -Item $item -Registry $Registry
            $removedSize += $item.Size
            $removedCount++
            Write-WtwHost ' done' -ForegroundColor Green
        } catch {
            Write-WtwHost " 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-WtwHost ''
    Write-WtwHost " Removed $removedCount worktrees, freed $(Format-Size $removedSize)" -ForegroundColor Green
}

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

    Write-WtwHost ' Scanning for extra git worktrees...' -ForegroundColor Cyan

    $items = @(Get-WtwLinkedWorktreeItems -Registry $Registry -IncludeRegistered -IncludeUnregistered -IncludeDetached)
    if ($items.Count -eq 0) {
        Write-WtwHost ' No extra git worktrees found.' -ForegroundColor Green
        return
    }

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

    Write-WtwHost ''
    Write-WtwHost " Found $($items.Count) extra git worktree(s) ($(Format-Size $totalSize) total)" -ForegroundColor Yellow
    Write-WtwHost ''
    Format-WtwTable $items @('Repo', 'Branch', 'Status', 'Merged', 'Dirty', 'SizeStr', 'Path')
    Write-WtwHost ''

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

    $items = Select-WtwCleanItems -Items $items -Force:$Force -Noun 'extra git worktrees'
    if ($null -eq $items) { return }

    $removedSize = 0
    $removedCount = 0
    foreach ($item in $items) {
        Write-WtwHost " Removing: $($item.Path)..." -ForegroundColor Cyan -NoNewline
        try {
            Remove-WtwCleanWorktreeItem -Item $item -Registry $Registry
            $removedSize += $item.Size
            $removedCount++
            Write-WtwHost ' done' -ForegroundColor Green
        } catch {
            Write-WtwHost " 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-WtwHost ''
    Write-WtwHost " Removed $removedCount worktrees, freed $(Format-Size $removedSize)" -ForegroundColor Green
}

function Remove-WtwCleanWorktreeItem {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Item,
        [Parameter(Mandatory)] $Registry
    )

    $isRegistered = (Get-WtwPropertyValue -Object $Item -Name 'Status') -eq 'registered'
    $taskName = Get-WtwPropertyValue -Object $Item -Name 'Task'
    if ($isRegistered -and $taskName) {
        Remove-WtwWorktree -Name $taskName -Force
        if (-not (Test-Path $Item.Path)) { return }
    }

    $parentRepo = Get-WtwPropertyValue -Object $Item -Name 'MainPath'
    if (-not $parentRepo) {
        foreach ($rn in (Get-WtwPropertyNames -Object $Registry.repos)) {
            $r = $Registry.repos.$rn
            $sameRepoName = $Item.Repo -eq (Split-Path $r.mainPath -Leaf)
            $pathUnderMain = $Item.Path.StartsWith($r.mainPath)
            if ($pathUnderMain -or $sameRepoName) {
                $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
    }
}

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

    Write-WtwHost ' 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-WtwHost ' No leftover merged branches found.' -ForegroundColor Green
        if ($skipped.Count -gt 0) {
            Write-WtwHost " Still checked out in a worktree (use wtw remove): $($skipped -join ', ')" -ForegroundColor DarkGray
        }
        return
    }

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

    if ($DryRun) {
        Write-WtwHost ' (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-WtwHost " Deleting $($item.Repo)/$($item.Branch)..." -ForegroundColor Cyan -NoNewline
        git -C $mainPath branch -d $item.Branch 2>$null
        if ($LASTEXITCODE -eq 0) {
            $removed++
            Write-WtwHost ' done' -ForegroundColor Green
        } else {
            Write-WtwHost ' FAILED (not fully merged, or still checked out)' -ForegroundColor Red
        }
    }

    Write-WtwHost ''
    Write-WtwHost " 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"
}