private/Get-WtwUntrackedWorktree.ps1

function Get-WtwUntrackedWorktree {
    <#
    .SYNOPSIS
        Git worktrees of registered repos that wtw does not track yet.
    .DESCRIPTION
        Other tools (T3 Code, Codex, Cursor, plain ``git worktree add``) create
        worktrees next to a repo wtw already tracks. They show up here so
        ``wtw list`` can display them and ``wtw go`` / ``wtw open`` can adopt
        them on first use. The primary checkout, bare entries, and trees whose
        folder is gone (prunable) are skipped.
    .OUTPUTS
        PSCustomObject with RepoName, RepoEntry, Path, Branch, Detached, TaskName.
        TaskName is the folder name with the ``<repo>_`` prefix stripped, which is
        the key ``wtw add`` would register it under.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Registry,

        [string] $RepoName
    )

    $items = [System.Collections.Generic.List[object]]::new()
    foreach ($name in (Get-WtwPropertyNames -Object $Registry.repos)) {
        if ($RepoName -and $name -ne $RepoName) { continue }
        $repo = $Registry.repos.$name
        $mainPath = Get-WtwPropertyValue -Object $repo -Name 'mainPath'
        if (-not $mainPath -or -not (Test-Path -LiteralPath $mainPath)) { continue }

        $registeredMap = Get-WtwRegisteredWorktreePathMap -RepoEntry $repo
        $listed = @(Get-WtwGitWorktreeList -RepoPath $mainPath)
        $index = 0
        foreach ($wt in $listed) {
            $index++
            if ($wt.Bare -or $wt.Prunable) { continue }
            if ($index -eq 1 -or (Test-WtwSamePath $wt.Path $mainPath)) { continue }
            if ($registeredMap.Contains($wt.Path)) { continue }
            $canonical = Resolve-WtwRealPath $wt.Path
            if ($canonical -and $registeredMap.Contains($canonical)) { continue }
            if (-not (Test-Path -LiteralPath $wt.Path)) { continue }

            $leaf = Split-Path $wt.Path -Leaf
            $taskName = $leaf -replace "^$([regex]::Escape($name))_", ''
            if (-not $taskName) { $taskName = $leaf }

            [void]$items.Add([pscustomobject]@{
                    RepoName  = $name
                    RepoEntry = $repo
                    Path      = $wt.Path
                    Branch    = $wt.Branch
                    Detached  = [bool]$wt.Detached
                    TaskName  = $taskName
                })
        }
    }
    return @($items)
}

function Find-WtwUntrackedWorktree {
    <#
    .SYNOPSIS
        Untracked worktrees whose folder, task name, branch, or path match a query.
    .DESCRIPTION
        Tries exact, then prefix, then substring (case-insensitive) and returns
        the first non-empty tier, so an exact folder name beats a longer one
        that merely contains it. An absolute path matches that worktree's path.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Registry,

        [Parameter(Mandatory)]
        [string] $Name,

        [string] $RepoName
    )

    $untracked = @(Get-WtwUntrackedWorktree -Registry $Registry -RepoName $RepoName)
    if ($untracked.Count -eq 0) { return @() }

    if ([System.IO.Path]::IsPathRooted($Name)) {
        return @($untracked | Where-Object { Test-WtwSamePath $_.Path $Name })
    }

    $needle = (ConvertTo-WtwLookupKey $Name)
    foreach ($mode in @('Exact', 'Prefix', 'Substring')) {
        $hits = @($untracked | Where-Object {
                $item = $_
                $candidates = @(
                    $item.TaskName
                    (Split-Path $item.Path -Leaf)
                    $item.Branch
                ) | Where-Object { $_ } | ForEach-Object { ConvertTo-WtwLookupKey $_ }
                $matched = $false
                foreach ($candidate in $candidates) {
                    $matched = switch ($mode) {
                        'Exact' { $candidate -eq $needle }
                        'Prefix' { $candidate.StartsWith($needle) }
                        'Substring' { $candidate.Contains($needle) }
                    }
                    if ($matched) { break }
                }
                $matched
            })
        if ($hits.Count -gt 0) { return $hits }
    }
    return @()
}

function Add-WtwUntrackedTarget {
    <#
    .SYNOPSIS
        Adopt one untracked worktree and return it in Resolve-WtwTarget's shape.
    .DESCRIPTION
        Runs ``wtw add`` (color, workspace file, editor registrations). The task
        key gets a numeric suffix when the folder name is already taken.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Item,

        [string] $Name
    )

    $existing = Get-WtwPropertyNames -Object $Item.RepoEntry.worktrees
    $task = $Item.TaskName
    $suffix = 2
    while ($existing -contains $task) {
        $task = "$($Item.TaskName)-$suffix"
        $suffix++
    }

    Write-WtwHost " '$Name' is an untracked worktree of $($Item.RepoName): $($Item.Path)" -ForegroundColor Cyan
    Write-WtwHost ' Adding it to wtw...' -ForegroundColor Cyan
    $null = Add-WtwEntry -Path $Item.Path -Repo $Item.RepoName -Task $task

    $repo = (Get-WtwRegistry).repos.($Item.RepoName)
    if (-not $repo -or ((Get-WtwPropertyNames -Object $repo.worktrees) -notcontains $task)) {
        return $null
    }
    return [PSCustomObject]@{
        RepoName      = $Item.RepoName
        RepoEntry     = $repo
        TaskName      = $task
        WorktreeEntry = $repo.worktrees.$task
    }
}

function Remove-WtwGoneWorktree {
    <#
    .SYNOPSIS
        Drop registry entries for worktrees git has already removed.
    .DESCRIPTION
        A registered worktree is gone when its folder no longer exists and git
        either no longer lists it or lists it as prunable. Its registry entry,
        color assignment, and generated workspace file are removed. A repo
        whose main checkout is missing, or where git cannot list worktrees, is
        left alone: there is nothing to compare against.
    .OUTPUTS
        One "<repo>/<task>" string per removed entry.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Registry
    )

    $removed = [System.Collections.Generic.List[string]]::new()
    foreach ($repoName in (Get-WtwPropertyNames -Object $Registry.repos)) {
        $repo = $Registry.repos.$repoName
        $worktrees = Get-WtwPropertyValue -Object $repo -Name 'worktrees'
        $taskNames = Get-WtwPropertyNames -Object $worktrees
        if (-not $taskNames) { continue }
        $mainPath = Get-WtwPropertyValue -Object $repo -Name 'mainPath'
        if (-not $mainPath -or -not (Test-Path -LiteralPath $mainPath)) { continue }

        $listed = @(Get-WtwGitWorktreeList -RepoPath $mainPath)
        if ($listed.Count -eq 0) { continue }
        $live = @($listed | Where-Object { -not $_.Prunable })

        $gone = @(foreach ($task in $taskNames) {
                $path = Get-WtwPropertyValue -Object $worktrees.$task -Name 'path'
                if (-not $path -or (Test-Path -LiteralPath $path)) { continue }
                $stillListed = @($live | Where-Object { Test-WtwSamePath $_.Path $path }).Count -gt 0
                if (-not $stillListed) { $task }
            })
        if ($gone.Count -eq 0) { continue }

        $kept = [PSCustomObject]@{}
        foreach ($prop in $worktrees.PSObject.Properties) {
            if ($prop.Name -in $gone) {
                $workspace = Get-WtwPropertyValue -Object $prop.Value -Name 'workspace'
                if ($workspace -and (Test-Path -LiteralPath $workspace)) {
                    Remove-Item -LiteralPath $workspace -Force -ErrorAction SilentlyContinue
                }
                [void]$removed.Add("$repoName/$($prop.Name)")
            } else {
                $kept | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value
            }
        }
        $repo.worktrees = $kept
    }

    if ($removed.Count -eq 0) { return @() }
    Save-WtwRegistry $Registry

    $colors = Get-WtwColors
    $assignments = [PSCustomObject]@{}
    foreach ($prop in $colors.assignments.PSObject.Properties) {
        if ($prop.Name -notin $removed) {
            $assignments | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value
        }
    }
    $colors.assignments = $assignments
    Save-WtwColors $colors

    return @($removed)
}