Public/Find-StaleBranch.ps1

function Find-StaleBranch {
    <#
    .SYNOPSIS
        Find local branches whose remote branch no longer exists.
    .DESCRIPTION
        Compares local branches against remote refs to identify stale branches
        that were deleted from the remote (e.g., after a PR was merged or abandoned).
 
        Optionally queries Azure DevOps for PR status to explain why the branch
        was deleted (completed/merged, abandoned, or manually deleted).
    .PARAMETER Remote
        The remote to check against. Defaults to 'origin'.
    .PARAMETER User
        Filter to branches matching user/<name>/*. Defaults to the current
        git user name derived from user.email config.
    .PARAMETER IncludePrStatus
        Query Azure DevOps for PR status on each stale branch. This makes
        one API call per stale branch and is slower.
    .PARAMETER All
        Include all local branches, not just those matching the user filter.
    .EXAMPLE
        Find-StaleBranch
        Lists stale branches for the current user.
    .EXAMPLE
        Find-StaleBranch -IncludePrStatus
        Lists stale branches with PR status from Azure DevOps.
    .EXAMPLE
        Find-StaleBranch -All
        Lists all stale branches regardless of user.
    .EXAMPLE
        Find-StaleBranch | Remove-Worktree
        Removes worktrees for stale branches.
    #>

    [OutputType('StaleBranchInfo')]
    [CmdletBinding()]
    param(
        [string]$Remote = 'origin',

        [string]$User,

        [switch]$IncludePrStatus,

        [switch]$All
    )
    process {
        # Determine user filter
        if (-not $All -and -not $User) {
            $email = git config user.email 2>$null
            if ($email -match '^([^@]+)@') {
                $User = $Matches[1]
            }
        }
        $userPrefix = if (-not $All -and $User) { "user/$User/" } else { $null }

        # Get all local branches
        $localBranches = git --no-pager for-each-ref --format='%(refname:short)|%(upstream:short)|%(upstream:track)' refs/heads/ 2>$null
        if ($LASTEXITCODE -ne 0) {
            throw "Not a git repository (or git failed) in '$((Get-Location).Path)'."
        }
        $candidates = @()
        foreach ($line in $localBranches) {
            $parts = $line -split '\|', 3
            if ($parts.Count -lt 3) { continue }
            $branch = $parts[0]
            $upstream = $parts[1]
            $upstreamTrack = $parts[2]

            # Keep branches with no upstream or an upstream whose tracking ref is gone.
            if ($upstream -and $upstreamTrack -ne '[gone]') { continue }

            # Apply user filter
            if ($userPrefix -and -not $branch.StartsWith($userPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { continue }

            $candidates += $branch
        }

        if ($candidates.Count -eq 0) { return }

        # Batch-fetch all remote refs matching user prefix in one call
        $remoteRefSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $lsRemoteFilter = if ($userPrefix) { "refs/heads/$userPrefix*" } else { 'refs/heads/*' }
        $remoteRefs = git --no-pager ls-remote --heads $Remote $lsRemoteFilter 2>$null
        foreach ($refLine in $remoteRefs) {
            if ($refLine -match '\trefs/heads/(.+)$') {
                $remoteRefSet.Add($Matches[1]) | Out-Null
            }
        }

        # Build worktree lookup for path info
        $worktreePaths = @{}
        try {
            $worktrees = Get-Worktrees
            foreach ($wt in $worktrees) {
                $worktreePaths[$wt.Branch] = $wt.Path
            }
        } catch { }

        # ADO context for PR lookups
        $adoContext = $null
        if ($IncludePrStatus) {
            $gitUrl = git config --get "remote.$Remote.url" 2>$null
            if ($gitUrl -match 'dev\.azure\.com/(?<org>[^/]+)/(?<project>[^/]+)/_git/(?<repo>.+)$' -or
                $gitUrl -match '(?<org>[^/]+)\.visualstudio\.com/(?:DefaultCollection/)?(?<project>[^/]+)/_git/(?<repo>.+)$') {
                $adoContext = @{
                    Org     = "https://dev.azure.com/$($Matches['org'])"
                    Project = $Matches['project']
                    Repo    = $Matches['repo']
                }
            }
        }

        # Classify each candidate
        foreach ($branch in $candidates) {
            $existsOnRemote = $remoteRefSet.Contains($branch)
            if ($existsOnRemote) { continue }

            $result = [PSCustomObject]@{
                PSTypeName     = 'StaleBranchInfo'
                Branch         = $branch
                Path           = $worktreePaths[$branch]
                ExistsOnRemote = $false
                PrStatus       = $null
                PrId           = $null
                PrTitle        = $null
            }

            # Optional ADO PR lookup
            if ($IncludePrStatus -and $adoContext) {
                try {
                    $prJson = az repos pr list `
                        --source-branch $branch `
                        --status all `
                        --repository $adoContext.Repo `
                        --project $adoContext.Project `
                        --org $adoContext.Org `
                        --query '[0].{id:pullRequestId,title:title,status:status}' `
                        -o json 2>$null | ConvertFrom-Json

                    if ($prJson) {
                        $result.PrStatus = $prJson.status
                        $result.PrId = $prJson.id
                        $result.PrTitle = $prJson.title
                    }
                } catch {
                    Write-Verbose "Failed to query PR for $branch`: $_"
                }
            }

            $result
        }
    }
}