private/Get-WtwGitWorktreeList.ps1

function Get-WtwGitWorktreeList {
    <#
    .SYNOPSIS
        Parse ``git worktree list --porcelain`` into objects.
    .DESCRIPTION
        First entry is the primary checkout. Linked trees follow. Paths are
        the strings git emitted (not yet realpath-canonicalized).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $RepoPath
    )

    $items = [System.Collections.Generic.List[object]]::new()
    if (-not (Test-Path $RepoPath)) { return @() }

    $porcelain = git -C $RepoPath worktree list --porcelain 2>$null
    if (-not $porcelain) { return @() }

    $current = $null
    foreach ($line in @($porcelain)) {
        if ($line -match '^worktree (.+)$') {
            if ($null -ne $current) {
                [void]$items.Add([pscustomobject]$current)
            }
            $current = [ordered]@{
                Path      = $Matches[1]
                Head      = $null
                Branch    = $null
                Detached  = $false
                Bare      = $false
                Locked    = $false
                Prunable  = $false
            }
            continue
        }
        if ($null -eq $current) { continue }
        if ($line -match '^HEAD (.+)$') {
            $current.Head = $Matches[1]
            continue
        }
        if ($line -match '^branch refs/heads/(.+)$') {
            $current.Branch = $Matches[1]
            continue
        }
        if ($line -eq 'detached') {
            $current.Detached = $true
            continue
        }
        if ($line -eq 'bare') {
            $current.Bare = $true
            continue
        }
        if ($line -match '^locked') {
            $current.Locked = $true
            continue
        }
        if ($line -match '^prunable') {
            $current.Prunable = $true
        }
    }
    if ($null -ne $current) {
        [void]$items.Add([pscustomobject]$current)
    }
    return @($items)
}

function Test-WtwSamePath {
    <#
    .SYNOPSIS
        Compare two filesystem paths after symlink / firmlink resolution.
    #>

    [CmdletBinding()]
    param(
        [string] $Left,
        [string] $Right
    )

    if ([string]::IsNullOrWhiteSpace($Left) -or [string]::IsNullOrWhiteSpace($Right)) {
        return $false
    }

    $leftResolved = Resolve-WtwRealPath $Left
    $rightResolved = Resolve-WtwRealPath $Right
    if ($IsWindows) {
        $leftNorm = $leftResolved.TrimEnd('\')
        $rightNorm = $rightResolved.TrimEnd('\')
        return $leftNorm -ieq $rightNorm
    }

    $leftNorm = $leftResolved.TrimEnd('/')
    $rightNorm = $rightResolved.TrimEnd('/')
    return $leftNorm -ceq $rightNorm
}

function Get-WtwRegisteredWorktreePathMap {
    <#
    .SYNOPSIS
        Map worktree path (raw and canonical) → registry task name.
    #>

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

    $map = @{}
    $worktrees = Get-WtwPropertyValue -Object $RepoEntry -Name 'worktrees'
    foreach ($taskName in (Get-WtwPropertyNames -Object $worktrees)) {
        $wt = $worktrees.$taskName
        $path = Get-WtwPropertyValue -Object $wt -Name 'path'
        if (-not $path) { continue }
        $map[$path] = $taskName
        $canonical = Resolve-WtwRealPath $path
        if ($canonical) { $map[$canonical] = $taskName }
    }
    return $map
}

function Test-WtwWorktreeDirty {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Path
    )

    if (-not (Test-Path $Path)) { return $false }
    $status = git -C $Path status --porcelain 2>$null
    return [bool]$status
}

function Test-WtwBranchMergedInto {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $RepoPath,

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

        [Parameter(Mandatory)]
        [string] $DefaultBranch
    )

    git -C $RepoPath merge-base --is-ancestor $Branch $DefaultBranch 2>$null
    return ($LASTEXITCODE -eq 0)
}

function Get-WtwLinkedWorktreeItems {
    <#
    .SYNOPSIS
        Extra git worktrees on registered repos (not the primary checkout).
    .DESCRIPTION
        Used by ``wtw clean --worktrees`` (unregistered + detached) and
        ``wtw clean --linked`` (those plus wtw-tracked extras). Skips the
        directory the command is running from so you cannot delete the
        tree you are standing in.
    #>

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

        [switch] $IncludeRegistered,
        [switch] $IncludeUnregistered,
        [switch] $IncludeDetached
    )

    $includeUnregistered = [bool]$IncludeUnregistered
    $includeRegistered = [bool]$IncludeRegistered
    $includeDetached = [bool]$IncludeDetached
    $noKindSelected = -not $includeRegistered -and -not $includeUnregistered
    if ($noKindSelected) {
        $includeUnregistered = $true
    }

    $currentPath = Resolve-WtwRealPath (Get-Location).Path
    $items = @()

    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 }

        $defaultBranch = Get-WtwDefaultBranch -RepoPath $mainPath
        $registeredMap = Get-WtwRegisteredWorktreePathMap -RepoEntry $repo
        $listed = @(Get-WtwGitWorktreeList -RepoPath $mainPath)
        $index = 0
        foreach ($wt in $listed) {
            $index++
            if ($wt.Bare) { continue }

            $isPrimaryCheckout = ($index -eq 1) -or (Test-WtwSamePath $wt.Path $mainPath)
            if ($isPrimaryCheckout) { continue }
            if (Test-WtwSamePath $wt.Path $currentPath) { continue }

            $canonical = Resolve-WtwRealPath $wt.Path
            $taskName = $null
            if ($registeredMap.Contains($canonical)) {
                $taskName = $registeredMap[$canonical]
            } elseif ($registeredMap.Contains($wt.Path)) {
                $taskName = $registeredMap[$wt.Path]
            }
            $isRegistered = $null -ne $taskName
            $isDetached = [bool]$wt.Detached

            $includeThis = $false
            if ($isDetached -and $includeDetached) {
                $includeThis = $true
            } elseif ($isRegistered -and $includeRegistered) {
                $includeThis = $true
            } elseif (-not $isRegistered -and $includeUnregistered) {
                $includeThis = $true
            }
            if (-not $includeThis) { continue }

            $exists = Test-Path $wt.Path
            $size = if ($exists) { Get-DirectorySize $wt.Path } else { [long]0 }
            $modified = '-'
            if ($exists) {
                $modified = (Get-Item $wt.Path).LastWriteTime.ToString('yyyy-MM-dd')
            }
            $dirty = '-'
            if ($exists) {
                $dirty = if (Test-WtwWorktreeDirty $wt.Path) { 'yes' } else { 'no' }
            }
            $branch = '-'
            if ($wt.Branch) {
                $branch = $wt.Branch
            } elseif ($isDetached) {
                $branch = '(detached)'
            }
            $merged = '-'
            $hasMergeCheck = $wt.Branch -and $defaultBranch
            if ($hasMergeCheck) {
                $isMerged = Test-WtwBranchMergedInto -RepoPath $mainPath -Branch $wt.Branch -DefaultBranch $defaultBranch
                $merged = if ($isMerged) { 'yes' } else { 'no' }
            }

            $status = if ($isRegistered) { 'registered' } else { 'unregistered' }
            $type = if ($isDetached) { 'detached' } else { 'linked' }

            $items += [PSCustomObject]@{
                Source   = 'git'
                Path     = $wt.Path
                Repo     = $repoName
                Branch   = $branch
                Status   = $status
                Task     = $taskName
                Merged   = $merged
                Dirty    = $dirty
                Size     = $size
                SizeStr  = Format-Size $size
                Modified = $modified
                Type     = $type
                MainPath = $mainPath
            }
        }
    }

    return @($items)
}