Get-StaleGitBranches.ps1
<#PSScriptInfo
.VERSION 1.0.0 .GUID ea8d4667-4fd9-439f-bf42-e4b12539858f .AUTHOR Gary McNickle .PROJECTURI https://github.com/gmcnickle/stale-branches .COPYRIGHT © 2025 Gary McNickle .LICENSEURI https://github.com/gmcnickle/stale-branches/blob/main/LICENSE.md .TAGS git stale branches html report powershell #> <# .SYNOPSIS Generates an HTML report of stale Git branches that have not been merged to master. .DESCRIPTION This script scans the specified Git repository (or the current directory if none is provided), identifies branches not fully merged to master, and produces a styled HTML report of those branches along with commit stats, authorship, and PR status. It supports caching for performance and includes options for filtering and limiting results. .PARAMETER OlderThanDays Filters out branches whose latest commit is more recent than the specified number of days ago. Set to 0 to disable this filter (default). .PARAMETER IncludeRemote If specified, includes remote branches in the analysis (e.g., origin/*). Otherwise, only local branches are included. .PARAMETER CleanCache If specified, deletes the cached Git command results before execution. Useful when testing or if repo contents have changed. .PARAMETER OutputPath The full path and filename for the generated HTML report. If not specified, defaults to "stale-branches.html" in the user's Documents folder. .PARAMETER Location Optional. The path to the Git repository to analyze. If not provided, the current directory is used. .PARAMETER Limit Optional. Limits the number of branches analyzed, which can be useful during testing or debugging. A value of 0 means no limit (default). .EXAMPLE .\Get-StaleGitBranches.ps1 -OlderThanDays 30 -IncludeRemote -CleanCache -Limit 50 Generates a report of up to 50 branches (including remote) that haven’t been updated in the last 30 days, after clearing any cached Git output. #> param ( [Parameter()] [int]$OlderThanDays = 0, [Parameter()] [switch]$IncludeRemote = $false, [Parameter()] [switch]$CleanCache = $false, [Parameter()] [string]$OutputPath = "", [Parameter()] [string]$Location = "", [Parameter()] [int]$Limit = 0 ) function Remove-CacheFolder { $cacheDir = Join-Path ([System.IO.Path]::GetTempPath()) 'git-cache' if (Test-Path $cacheDir) { Remove-Item -Path $cacheDir -Recurse } } function Get-GitHubRepoInfo { $url = git remote get-url origin 2>$null if (-not $url) { return $null } # HTTPS format: https://host/owner/repo.git if ($url -match 'https?://[^/]+/([^/]+)/([^/]+?)(\.git)?$') { return $matches[1], $matches[2] } # SSH format: git@host:owner/repo.git (host can be anything) if ($url -match 'git@[^:]+:([^/]+)/([^/]+?)(\.git)?$') { return $matches[1], $matches[2] } Write-Warning "Unable to parse GitHub remote URL: $url" return "Unknown", "Unknown" } function Get-CachedGitOutput { param ( [string]$GitCommand, [int]$MaxAgeMinutes = 60 ) $hash = [System.BitConverter]::ToString( [System.Security.Cryptography.SHA256]::Create().ComputeHash( [System.Text.Encoding]::UTF8.GetBytes($GitCommand) ) ) -replace '-', '' $cacheDir = Join-Path ([System.IO.Path]::GetTempPath()) 'git-cache' if (-not (Test-Path $cacheDir)) { New-Item -Path $cacheDir -ItemType Directory | Out-Null } $cachePath = Join-Path $cacheDir "$hash.txt" if (Test-Path $cachePath) { $age = (Get-Date) - (Get-Item $cachePath).LastWriteTime if ($age.TotalMinutes -lt $MaxAgeMinutes) { return Get-Content $cachePath } } $output = Invoke-Expression $GitCommand Set-Content -Path $cachePath -Value $output return $output } function Write-StaleBranchHtmlReport { param ( [Parameter(Mandatory)] [array]$Results, [Parameter(Mandatory)] [array]$AuthorSummary, [Parameter(Mandatory)] [string]$OutputPath ) $HtmlTemplate = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Stale Git Branch Report</title> <style> body { font-family: "Segoe UI", Tahoma, sans-serif; background-color: #1e1e2f; color: #f0f0f0; padding: 20px; } h1, h2 { color: #ffffff; border-bottom: 1px solid #444; padding-bottom: 4px; margin-top: 1.5em; } table { width: 100%; border-collapse: collapse; margin-bottom: 1em; } th, td { padding: 10px; text-align: left; font-size: 14px; } th { background-color: #2e3d55; color: #c0e0ff; border-bottom: 2px solid #406080; font-weight: bold; position: sticky; top: 0; z-index: 10; } td { background-color: #2a2a3a; border-bottom: 1px solid #333; } .summary-container, .details-container { max-height: 400px; overflow-y: auto; border: 1px solid #444; padding: 10px; background-color: #262636; position: relative; } input[type="text"] { background-color: #2e2e3e; color: #f0f0f0; border: 1px solid #555; padding: 6px 10px; width: 100%; margin-bottom: 10px; box-sizing: border-box; } a.branch-link { color: #66ccff; text-decoration: none; } a.branch-link:hover { text-decoration: underline; } .stale-high { background-color: #662222 !important; } .stale-mid { background-color: #665522 !important; } .stale-low { background-color: #224422 !important; } ::-webkit-scrollbar { width: 10px; } ::-webkit-scrollbar-thumb { background-color: #555; border-radius: 6px; } ::-webkit-scrollbar-track { background-color: #2a2a3a; } .split-logo { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .split-logo h1 { margin: 0; font-size: 1.8em; } .split-logo img { height: 100px; } </style> </head> <script> function applyFilters() { var authorInput = document.getElementById("authorFilter").value.toLowerCase(); var branchInput = document.getElementById("branchFilter").value.toLowerCase(); var authorTable = document.getElementById("authorTable"); var branchTable = document.getElementById("branchTable"); var matchingAuthors = new Set(); var matchingBranches = []; var branchRows = branchTable.getElementsByTagName("tr"); for (var i = 1; i < branchRows.length; i++) { var row = branchRows[i]; var branchCell = row.getElementsByTagName("td")[0]; var authorAttr = row.getAttribute("data-author") || ""; var branchText = branchCell.textContent || branchCell.innerText; var branchMatch = branchText.toLowerCase().includes(branchInput); var authorMatch = authorAttr.includes(authorInput); var showRow = (!branchInput || branchMatch) && (!authorInput || authorMatch); row.style.display = showRow ? "" : "none"; if (showRow) { matchingAuthors.add(authorAttr); matchingBranches.push(branchText.toLowerCase()); } } var authorRows = authorTable.getElementsByTagName("tr"); for (var i = 1; i < authorRows.length; i++) { var row = authorRows[i]; var authorCell = row.getElementsByTagName("td")[0]; var authorText = authorCell.textContent || authorCell.innerText; row.style.display = matchingAuthors.has(authorText.toLowerCase()) ? "" : "none"; } } function clearFilters() { document.getElementById("authorFilter").value = ""; document.getElementById("branchFilter").value = ""; applyFilters(); } </script> <body> <div class="report-header split-logo"> <div> <h1>Stale Git Branch Report</h1> <h3 style="color:#ccc; margin-top: -0.15em;">Generated for: {REPO}</h3> </div> <img src="https://raw.githubusercontent.com/gmcnickle/gittools/main/assets/gitTools-dk.png" alt="Logo"> </div> <h2>Summary</h2> <table class="summary-table"> <tr><th>Total Stale Branches</th><td>{TOTAL}</td></tr> <tr><th>Generated On</th><td>{DATE}</td></tr> </table> <h2>By Author</h2> <input type="text" id="authorFilter" placeholder="Filter by author" oninput="applyFilters()" style="margin-bottom:10px; padding:4px; width: 20%; box-sizing: border-box;"> <div class="summary-container"> <table class="summary-table" id="authorTable"> <tr><th>Author</th><th>Branch Count</th></tr> {AUTHOR_ROWS} </table> </div> <h2>By Branch</h2> <input type="text" id="branchFilter" placeholder="Filter by branch" oninput="applyFilters()" style="margin-bottom:10px; padding:4px; width: 20%; box-sizing: border-box;"> <br> <button onclick="clearFilters()">🔄 Clear Filters</button> <h2>Detailed Branch List</h2> <div class="details-container" id="branchTable"> <table class="details-table" > <tr> <th>Branch</th> <th>Last Commit</th> <th>Age (Days)</th> <th>Author</th> <th>Commits</th> <th>Files</th> <th>Lines Added</th> <th>Lines Deleted</th> <th>Net Δ</th> <th>Merge Status</th> <th>Open PR</th> <th>Message</th> </tr> {BRANCH_ROWS} </table> </div> <footer style="margin-top: 40px; padding-top: 10px; border-top: 1px solid #444; text-align: center; font-size: 0.9em; color: #888;"> <p>© $(Get-Date -Format 'yyyy') <a href="https://github.com/gmcnickle" target="_blank" style="color: #66ccff; text-decoration: none;">Gary McNickle</a>. All rights reserved. 🍂</p> </footer> </body> </html> "@ $GitHubOwner, $GitHubRepo = Get-GitHubRepoInfo $authorRows = ($AuthorSummary | ForEach-Object { "<tr><td>$($_.Name)</td><td>$($_.Count)</td></tr>" }) -join "`n" $branchRows = ($Results | ForEach-Object { $branch = $_.Branch $commitDate = $_.LastCommit.ToString("yyyy-MM-dd") $age = $_.AgeDays $author = $_.Author $message = ($_.Message -replace '\|', '-') -replace '\s+', ' ' if ($message.Length -gt 80) { $message = $message.Substring(0,77) + "..." } $commitCount = $_.CommitCount $filesChanged = $_.FilesChanged $netChange = $_.NetChange $linesAdded = $_.LinesAdded $linesDeleted = $_.LinesDeleted $mergeStatus = $_.MergeStatus $openPR = $_.HasOpenPR $class = if ($age -ge 90) { "stale-high" } elseif ($age -ge 30) { "stale-mid" } else { "stale-low" } $cleanBranch = $branch -replace '^origin/', '' $branchUrl = "https://github.com/$GitHubOwner/$GitHubRepo/tree/$cleanBranch" @( "<tr class='$class' data-author='$($author.ToLower())'>", "<td><a href='$branchUrl' target='_blank' class='branch-link'>$branch</a></td>", "<td>$commitDate</td>", "<td>$age</td>", "<td>$author</td>", "<td>$commitCount</td>", "<td>$filesChanged</td>", "<td>$linesAdded</td>", "<td>$linesDeleted</td>", "<td>$netChange</td>", "<td>$mergeStatus</td>", "<td>$openPR</td>", "<td>$message</td>", "</tr>" ) -join '' }) -join "`n" $htmlContent = $HtmlTemplate -replace '{TOTAL}', $Results.Count $htmlContent = $htmlContent -replace '{DATE}', (Get-Date).ToString("yyyy-MM-dd HH:mm") $htmlContent = $htmlContent -replace '{AUTHOR_ROWS}', $authorRows.Trim() $htmlContent = $htmlContent -replace '{BRANCH_ROWS}', $branchRows.Trim() $htmlContent = $htmlContent -replace '{REPO}', "$GitHubOwner/$GitHubRepo" Set-Content -Path $OutputPath -Value $htmlContent -Encoding UTF8 Write-Host "HTML report saved to $OutputPath" } function Get-CommitInfo($branch) { $cmd = "git log -1 --pretty=format:`"%ci|%an|%s`" $branch" $logOutput = Get-CachedGitOutput $cmd if (-not $logOutput -or $logOutput.Count -eq 0) { Write-Warning "No commits found for $branch" return $null } $commitInfo = $logOutput | Select-Object -First 1 $parts = $commitInfo -split '\|', 3 if ($parts.Count -lt 3) { Write-Warning "Invalid commit format for $($branch): $($commitInfo)" return $null } return @{ Date = [datetimeoffset]::Parse($parts[0]) Author = $parts[1] Message = $parts[2] } } function Test-IsBranchMerged($branch) { # 1. Does the branch tip exist in master? Get-CachedGitOutput "git merge-base --is-ancestor $branch master" if ($LASTEXITCODE -eq 0) { return $true } # 2. Does the branch have *any* commits not in master? $commitCount = Get-CachedGitOutput "git rev-list --count $branch --not master" if ([int]$commitCount -gt 0) { return $false } # 3. Fallback: look for merged PR $prCheck = Test-BranchWasMergedViaPR $branch if ($prCheck) { return $true } # Fallthrough (e.g., squash merge or moved pointer): flag as suspicious return $null } function Test-BranchWasMergedViaPR($branch) { try { $result = Get-CachedGitOutput "gh pr list --base master --head $branch --state merged" return -not [string]::IsNullOrWhiteSpace($result) } catch { Write-Warning "Unable to check merged PR for $($branch): $_" return $false } } function Test-BranchHasOpenPR($branch) { try { $output = Get-CachedGitOutput "gh pr list --base master --head $branch --state open" return -not [string]::IsNullOrWhiteSpace($output) } catch { Write-Warning "Failed to check PR for $($branch): $_" return $false } } function Get-BranchStats($branch, $base) { $commitCount = Get-CachedGitOutput "git rev-list --count $base..$branch" $diffStatsRaw = Get-CachedGitOutput "git -c diff.renameLimit=0 diff --shortstat $base $branch" $diffStats = ($diffStatsRaw -join "`n") $filesChanged = 0 $linesAdded = 0 $linesDeleted = 0 if ($diffStats -match '(\d+) files? changed') { $filesChanged = [int]$matches[1] } if ($diffStats -match '(\d+) insertions?') { $linesAdded = [int]$matches[1] } if ($diffStats -match '(\d+) deletions?') { $linesDeleted = [int]$matches[1] } return @{ CommitCount = [int]$commitCount FilesChanged = $filesChanged NetChange = $linesAdded - $linesDeleted LinesAdded = $linesAdded LinesDeleted = $linesDeleted } } function Get-FilteredBoundParams { param ( [hashtable]$AllParams, [string[]]$AllowList ) $result = @{} foreach ($key in $AllowList) { if ($AllParams.ContainsKey($key)) { $result[$key] = $AllParams[$key] } } return $result } function Get-StaleGitBranches { [CmdletBinding()] param ( [int]$OlderThanDays = 0, [switch]$IncludeRemote = $false, [string]$OutputPath = "", [int]$Limit = 0 ) $branchCmd = if ($IncludeRemote) { 'git branch -r --no-merged origin/master --format="%(refname:short)"' } else { 'git branch --no-merged master --format="%(refname:short)"' } $branches = Get-CachedGitOutput -GitCommand $branchCmd | Where-Object { $_ -and ($_ -notmatch "origin/HEAD") } | ForEach-Object { $_.Trim() } if ($Limit -gt 0) { $branches = $branches | Select-Object -First $Limit } $results = @() for ($i = 0; $i -lt $branches.Count; $i++) { $branch = $branches[$i] Write-Progress -Activity "Inspecting branches..." -Status "$branch ($($i+1)/$($branches.Count))" -PercentComplete (($i+1)/$branches.Count*100) $info = Get-CommitInfo $branch if (-not $info) { continue } $commitDate = $info.Date $author = $info.Author $message = $info.Message $ageDays = (New-TimeSpan -Start $commitDate.UtcDateTime -End (Get-Date).ToUniversalTime()).Days if ($OlderThanDays -gt 0 -and $ageDays -lt $OlderThanDays) { continue } $mergeStatus = Test-IsBranchMerged $branch if ($mergeStatus -eq $true) { continue } $hasPR = Test-BranchHasOpenPR $branch $staleIndicator, $mergeFlag = switch ($mergeStatus) { $false { if ($hasPR) { '⏳', "⏳ PR Open" } else { '❌', "❌ Unmerged" } } $null { if ($hasPR) { '⚠️⏳', "⚠️ Pointer Merged + PR Open" } else { '⚠️', "⚠️ Pointer Merged" } } default { '❓', "❓ Unknown" } } $base = Get-CachedGitOutput "git merge-base $branch master" $stats = Get-BranchStats $branch $base $results += [PSCustomObject]@{ Branch = $branch LastCommit = $commitDate AgeDays = $ageDays Author = $author Message = $message CommitCount = $stats.CommitCount FilesChanged = $stats.FilesChanged NetChange = $stats.NetChange LinesAdded = $stats.LinesAdded LinesDeleted = $stats.LinesDeleted Stale = $staleIndicator HasOpenPR = if ($hasPR) { "✅" } else { "❌" } MergeStatus = $mergeFlag } } Write-Progress -Activity "Inspecting branches..." -Completed $results = $results | Sort-Object LastCommit -Descending if (-not $OutputPath) { $OutputPath = Join-Path -Path ([Environment]::GetFolderPath("MyDocuments")) -ChildPath "stale-branches.html" } $authorSummary = $results | Group-Object Author | Sort-Object Count -Descending Write-StaleBranchHtmlReport -Results $results -AuthorSummary $authorSummary -OutputPath $OutputPath return $results } if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw "Git is not installed or not in the PATH." } if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { Write-Warning "GitHub CLI (gh) not found. PR-related info may be incomplete." } if ($CleanCache) { Remove-CacheFolder } if ($Location) { Set-Location $Location } $filteredParams = Get-FilteredBoundParams -AllParams $PSBoundParameters -AllowList @('OlderThanDays', 'IncludeRemote', 'OutputPath', 'Limit') Get-StaleGitBranches @filteredParams |