# Compare branches to detect unmerged commits (e.g. prod hotfix not merged to main) # Usage: local repo path OR Gitea owner/repo # -CompareSet full: main vs uat, main vs prod, uat vs prod param( [string]$RepoPath, [string]$Owner, [string]$Repo, [string]$BaseBranch = "main", [string]$HeadBranch = "prod", [ValidateSet("single", "full")] [string]$CompareSet = "single", [string]$SinceTag, [string]$SinceDate, [string]$ApiToken = $env:GITEA_TOKEN, [string]$GiteaBaseUrl = "http://afe.git:3000" ) $ErrorActionPreference = "Stop" $ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json" if (-not $ApiToken -and (Test-Path $ConfigPath)) { $cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json $ApiToken = $cfg.ApiToken if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl } } function Get-GitLog { param([string]$Path, [string]$Range, [string]$Since) $gitArgs = @("log", $Range, "--no-merges", "--format=%h | %an | %s") if ($Since) { $gitArgs += "--since=$Since" } $out = & git -C $Path $gitArgs 2>&1 if ($out) { $out } else { @() } } function Compare-Local { if (-not (Test-Path (Join-Path $RepoPath ".git"))) { Write-Host "Error: Not a git repo: $RepoPath"; exit 1 } cmd /c "git -C `"$RepoPath`" fetch origin >nul 2>&1" $since = if ($SinceDate) { $SinceDate } else { $null } $baseRef = "origin/$BaseBranch" $headRef = "origin/$HeadBranch" if ($SinceTag -and -not $SinceDate) { $tagDate = & git -C $RepoPath log -1 --format="%ci" $SinceTag 2>$null if ($tagDate) { $since = $tagDate.Trim().Split(" ")[0] } } Write-Host "=== Branch Compare: ${BaseBranch} vs ${HeadBranch} ===" -ForegroundColor Cyan Write-Host "Repo: $RepoPath" -ForegroundColor Gray Write-Host "" $inHeadNotBase = @(Get-GitLog -Path $RepoPath -Range "${baseRef}..${headRef}" -Since $since) $inBaseNotHead = @(Get-GitLog -Path $RepoPath -Range "${headRef}..${baseRef}" -Since $since) Write-Host "[!] Commits in ${HeadBranch} NOT in ${BaseBranch} (need merge to ${BaseBranch}):" -ForegroundColor Yellow if ($inHeadNotBase.Count -eq 0) { Write-Host " (none)" -ForegroundColor Green } else { $inHeadNotBase | ForEach-Object { Write-Host " $_" } Write-Host " Total: $($inHeadNotBase.Count) commits" -ForegroundColor Yellow } Write-Host "" Write-Host "[i] Commits in ${BaseBranch} NOT in ${HeadBranch} (${BaseBranch} is ahead):" -ForegroundColor Gray if ($inBaseNotHead.Count -eq 0) { Write-Host " (none)" -ForegroundColor Gray } else { $inBaseNotHead | ForEach-Object { Write-Host " $_" } Write-Host " Total: $($inBaseNotHead.Count) commits" -ForegroundColor Gray } if ($inHeadNotBase.Count -gt 0) { $byUser = $inHeadNotBase | ForEach-Object { $parts = $_ -split '\s*\|\s*' if ($parts.Count -ge 2) { $parts[1].Trim() } } | Where-Object { $_ } | Group-Object | Sort-Object Count -Descending Write-Host "" Write-Host "By user (unmerged):" -ForegroundColor Yellow $byUser | ForEach-Object { Write-Host " $($_.Name): $($_.Count) commits" } Write-Host "" Write-Host "ACTION: Merge ${HeadBranch} into ${BaseBranch} to sync hotfixes." -ForegroundColor Red } } function Compare-API { if (-not $ApiToken) { Write-Host "Error: ApiToken required for API mode"; exit 1 } if (-not $Owner -or -not $Repo) { Write-Host "Error: Owner and Repo required for API mode"; exit 1 } $ApiBase = "$GiteaBaseUrl/api/v1" $Headers = @{ "Authorization" = "token $ApiToken"; "Content-Type" = "application/json" } $basehead = "${BaseBranch}...${HeadBranch}" Write-Host "=== Branch Compare: ${BaseBranch} vs ${HeadBranch} ===" -ForegroundColor Cyan Write-Host "Repo: $Owner/$Repo (via API)" -ForegroundColor Gray Write-Host "" try { $cmp = Invoke-RestMethod -Uri "$ApiBase/repos/$Owner/$Repo/compare/$basehead" -Headers $Headers -Method Get $commits = if ($cmp.commits) { @($cmp.commits) } else { @() } Write-Host "[!] Commits in ${HeadBranch} NOT in ${BaseBranch} (need merge to ${BaseBranch}):" -ForegroundColor Yellow if ($commits.Count -eq 0) { Write-Host " (none)" -ForegroundColor Green } else { $commits | ForEach-Object { $author = $_.commit.author.name if ($_.author.login) { $author = $_.author.login } Write-Host " $($_.sha.Substring(0,7)) | $author | $($_.commit.message.Split("`n")[0])" } Write-Host " Total: $($commits.Count) commits" -ForegroundColor Yellow } Write-Host "" $baseheadRev = "${HeadBranch}...${BaseBranch}" $cmpRev = Invoke-RestMethod -Uri "$ApiBase/repos/$Owner/$Repo/compare/$baseheadRev" -Headers $Headers -Method Get $commitsRev = if ($cmpRev.commits) { @($cmpRev.commits) } else { @() } Write-Host "[i] Commits in ${BaseBranch} NOT in ${HeadBranch} (${BaseBranch} is ahead):" -ForegroundColor Gray if ($commitsRev.Count -eq 0) { Write-Host " (none)" -ForegroundColor Gray } else { $commitsRev | ForEach-Object { $author = $_.commit.author.name if ($_.author.login) { $author = $_.author.login } Write-Host " $($_.sha.Substring(0,7)) | $author | $($_.commit.message.Split("`n")[0])" } Write-Host " Total: $($commitsRev.Count) commits" -ForegroundColor Gray } if ($commits.Count -gt 0) { $byUser = $commits | ForEach-Object { if ($_.author.login) { $_.author.login } else { $_.commit.author.name } } | Group-Object | Sort-Object Count -Descending Write-Host "" Write-Host "By user (unmerged):" -ForegroundColor Yellow $byUser | ForEach-Object { Write-Host " $($_.Name): $($_.Count) commits" } Write-Host "" Write-Host "ACTION: Merge ${HeadBranch} into ${BaseBranch} to sync hotfixes." -ForegroundColor Red } } catch { $msg = if ($_.ErrorDetails.Message) { $_.ErrorDetails.Message } else { $_.Exception.Message } if ($msg -match "BaseNotExist|HeadNotExist|not found") { Write-Host " (branch not found - repo may not have ${BaseBranch}/${HeadBranch})" -ForegroundColor Gray } else { Write-Host " API Error: $msg" -ForegroundColor Red } } } $pairs = @() if ($CompareSet -eq "full") { $pairs = @( @{ Base = "main"; Head = "uat" }, @{ Base = "main"; Head = "prod" }, @{ Base = "uat"; Head = "prod" } ) } else { $pairs = @(@{ Base = $BaseBranch; Head = $HeadBranch }) } foreach ($p in $pairs) { $BaseBranch = $p.Base $HeadBranch = $p.Head if ($RepoPath) { Compare-Local } elseif ($Owner -and $Repo) { Compare-API } else { Write-Host "Error: Provide -RepoPath (local) OR -Owner and -Repo (API)" Write-Host " Local: .\branch_compare.ps1 -RepoPath 'd:\path\to\repo' -CompareSet full" Write-Host " API: .\branch_compare.ps1 -Owner G3SF -Repo g3fo-trade -CompareSet full" exit 1 } if ($pairs.Count -gt 1) { Write-Host "" } }