Find-StaleBranch.ps1
|
function Find-StaleBranch { <# .SYNOPSIS Find local branches whose remote branch no longer exists. .DESCRIPTION Compares local branches with gone upstreams against remote refs to identify stale branches that were deleted from the remote (e.g., after a PR was merged or abandoned). Local branches that were never pushed are excluded by default; use -IncludeNeverPushed to include them. 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 Path Directory inside the git working tree to inspect. Defaults to the current location. .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 IncludeNeverPushed Include local branches with no configured upstream. By default, only branches whose configured upstream is gone are considered stale. .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 -IncludeNeverPushed Lists stale branches and local branches that have never been pushed. .EXAMPLE Find-StaleBranch | Remove-Worktree Removes worktrees for stale branches. #> [OutputType('StaleBranchInfo')] [CmdletBinding()] param( [string]$Remote = 'origin', [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path, [string]$User, [switch]$IncludePrStatus, [switch]$IncludeNeverPushed, [switch]$All ) process { $repoPath = Resolve-GitRepositoryPath -Path $Path if (-not $repoPath) { return } # Issue #4 / CVE-2024-1874: these arguments flow through az.cmd, so # neutralize cmd.exe metacharacter injection before invoking az. $SafeBranchNamePattern = '^[A-Za-z0-9._/-]+$' $UnsafeAdoContextPattern = '[<>&|%!^`"()]' $decodeRemoteUrlComponent = { param([string]$Value) try { [System.Uri]::UnescapeDataString($Value) } catch { $Value } } # Determine user filter if (-not $All -and -not $User) { $email = git -C $repoPath 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 -C $repoPath --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 '$repoPath'." } $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 gone upstreams by default; optionally include local-only branches. if ($upstreamTrack -ne '[gone]' -and ($upstream -or -not $IncludeNeverPushed)) { 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/*' } # Only query the remote when it is actually configured. When no such # remote exists, every candidate is legitimately absent from it (this is # the documented -IncludeNeverPushed / local-only case), so an empty ref # set is correct rather than a failure. $remoteConfigured = [bool](git -C $repoPath remote get-url $Remote 2>$null) if ($remoteConfigured) { # Capture stderr and the exit code: a configured-but-unreachable # remote otherwise returns an empty ref set, which would misclassify # every candidate as stale. Fail loudly instead of guessing from # nothing. $remoteRefs = git -C $repoPath --no-pager ls-remote --heads $Remote $lsRemoteFilter 2>&1 if ($LASTEXITCODE -ne 0) { $detail = ($remoteRefs | ForEach-Object { "$_" }) -join ' ' Write-Error "Failed to list remote branches from '$Remote' (git ls-remote exit $LASTEXITCODE): $detail" return } 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 -Path $repoPath foreach ($wt in $worktrees) { $worktreePaths[$wt.Branch] = $wt.Path } } catch { } # ADO context for PR lookups $adoContext = $null if ($IncludePrStatus) { $gitUrl = git -C $repoPath 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>.+)$') { $org = & $decodeRemoteUrlComponent $Matches['org'] $project = & $decodeRemoteUrlComponent $Matches['project'] $repo = & $decodeRemoteUrlComponent $Matches['repo'] $adoContext = @{ Org = "https://dev.azure.com/$org" Project = $project Repo = $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) { if ($branch -notmatch $SafeBranchNamePattern) { Write-Warning "Skipping PR lookup for branch '$branch' because the branch contains an unsafe name." } elseif ($adoContext.Repo -match $UnsafeAdoContextPattern -or $adoContext.Project -match $UnsafeAdoContextPattern -or $adoContext.Org -match $UnsafeAdoContextPattern) { Write-Warning "Skipping PR lookup for branch '$branch' because the ADO remote context contains an unsafe name." } else { 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 } } } |