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" } |