From f50d4ab4a32b5e583ad66c04b9b188ed25b0b176 Mon Sep 17 00:00:00 2001 From: "man.zhong" Date: Fri, 27 Feb 2026 15:43:46 +0800 Subject: [PATCH] feat(git-control): add branch compare (main/uat/prod), org/single repo support Made-with: Cursor --- skills/git-control/SKILL.md | 42 +++++- skills/git-control/references/gitea_api.md | 8 + skills/git-control/scripts/branch_compare.ps1 | 142 ++++++++++++++++++ .../scripts/org_branch_compare.ps1 | 38 +++++ 4 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 skills/git-control/scripts/branch_compare.ps1 create mode 100644 skills/git-control/scripts/org_branch_compare.ps1 diff --git a/skills/git-control/SKILL.md b/skills/git-control/SKILL.md index 03dc4b8..9005362 100644 --- a/skills/git-control/SKILL.md +++ b/skills/git-control/SKILL.md @@ -1,6 +1,6 @@ --- name: git-control -description: Gitea repository and branch access control. Use when managing Git permissions: enable/disable branch protection (push, merge, read), user read/write permissions on repos, organization-wide repo access, listing orgs/repos, or bulk permission changes. Supports Gitea API (e.g. afe.git:3000). +description: Gitea repository and branch access control. Use when managing Git permissions: enable/disable branch protection (push, merge, read), user read/write permissions on repos, organization-wide repo access, listing orgs/repos, bulk permission changes, or comparing branches to detect unmerged commits (e.g. prod hotfix not in main). Supports Gitea API (e.g. afe.git:3000). --- # Git Control (Gitea) @@ -59,7 +59,7 @@ Add, remove, or change a user's permission on a repo. .\scripts\user_permission.ps1 -ApiToken "..." -Owner G3SF -Repo g3fo-trade -Username john -Action revoke ``` -### 4. Org-Wide Repo Protection +### 5. Org-Wide Repo Protection Apply branch protection to all repos under an organization. @@ -67,7 +67,42 @@ Apply branch protection to all repos under an organization. .\scripts\org_repos_protect.ps1 -ApiToken "..." -Org G3SF -BranchName uat -Action lock ``` -### 5. Org-Wide User Access +### 6. Branch Compare (Detect Unmerged Commits) + +Analyze differences between branches to find commits that need merging (e.g. prod hotfix not merged to main). + +| Mode | Script | Use Case | +|------|--------|----------| +| Single repo (local) | `branch_compare.ps1 -RepoPath` | Repo cloned locally | +| Single repo (API) | `branch_compare.ps1 -Owner -Repo` | No local clone | +| Single repo | `org_branch_compare.ps1 -Repo g3fo-trade` | One repo by name | +| Org-wide | `org_branch_compare.ps1` | All repos in org | + +**CompareSet**: +- `single`: BaseBranch vs HeadBranch only (default for branch_compare) +- `full`: main vs uat, main vs prod, uat vs prod (default for org_branch_compare) + +```powershell +# Single repo - full check (main/uat/prod) +.\scripts\branch_compare.ps1 -RepoPath "d:\path\to\g3fo-trade" -CompareSet full +.\scripts\branch_compare.ps1 -Owner G3SF -Repo g3fo-trade -CompareSet full + +# Single pair +.\scripts\branch_compare.ps1 -RepoPath "d:\path\to\repo" -BaseBranch main -HeadBranch uat + +# Single repo by name (org) +.\scripts\org_branch_compare.ps1 -Org G3SF -Repo g3fo-trade -CompareSet full + +# All G3SF repos - full check +.\scripts\org_branch_compare.ps1 -Org G3SF -CompareSet full -WorkspaceRoot "d:\AFE Git\G3SF\G3FO\server" + +# Filter by date or tag +.\scripts\branch_compare.ps1 -RepoPath "d:\path\to\repo" -CompareSet full -SinceDate "2025-01-01" +``` + +**Output**: Commits in `HeadBranch` NOT in `BaseBranch` = need to merge (hotfixes). Commits in `BaseBranch` NOT in `HeadBranch` = base is ahead (normal). + +### 7. Org-Wide User Access Revoke or grant a user's collaborator access across all org repos. @@ -84,6 +119,7 @@ Note: Org repos often use team permissions. For team-based access, use Gitea API |----------|-------------| | **Lock UAT for release** | Lock `uat` branch on specified repos before release freeze | | **Unlock for merge** | Temporarily allow merge (add user to merge allowlist) | +| **Detect unmerged hotfixes** | Compare prod vs main to find commits in prod not in main | | **Audit user access** | List repos a user can access (collaborators + org teams) | | **Bulk branch lock** | Lock same branch across multiple repos | | **Read-only maintenance** | Set repo to read-only during maintenance | diff --git a/skills/git-control/references/gitea_api.md b/skills/git-control/references/gitea_api.md index 6bd724e..fa13b4f 100644 --- a/skills/git-control/references/gitea_api.md +++ b/skills/git-control/references/gitea_api.md @@ -53,3 +53,11 @@ Auth: `Authorization: token {ApiToken}` | Remove member | DELETE | `/teams/{id}/members/{username}` | Team permission: `permission` = "none"|"read"|"write"|"admin"|"owner" + +## Branch Compare + +| Action | Method | Path | +|--------|--------|------| +| Compare | GET | `/repos/{owner}/{repo}/compare/{base}...{head}` | + +Returns commits in `head` that are not in `base`. Use `base...head` (three dots) format. diff --git a/skills/git-control/scripts/branch_compare.ps1 b/skills/git-control/scripts/branch_compare.ps1 new file mode 100644 index 0000000..83d218e --- /dev/null +++ b/skills/git-control/scripts/branch_compare.ps1 @@ -0,0 +1,142 @@ +# 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, "--oneline", "--no-merges") + 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) { + 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 { Write-Host " $($_.sha.Substring(0,7)) $($_.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 { Write-Host " $($_.sha.Substring(0,7)) $($_.commit.message.Split("`n")[0])" } + Write-Host " Total: $($commitsRev.Count) commits" -ForegroundColor Gray + } + if ($commits.Count -gt 0) { + 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 "" } +} diff --git a/skills/git-control/scripts/org_branch_compare.ps1 b/skills/git-control/scripts/org_branch_compare.ps1 new file mode 100644 index 0000000..6527d91 --- /dev/null +++ b/skills/git-control/scripts/org_branch_compare.ps1 @@ -0,0 +1,38 @@ +# Compare branches across all repos in an org - detect unmerged commits +# -CompareSet full: main vs uat, main vs prod, uat vs prod for each repo + +param( + [string]$Org = "G3SF", + [string]$Repo, + [string]$BaseBranch = "main", + [string]$HeadBranch = "prod", + [ValidateSet("single", "full")] + [string]$CompareSet = "full", + [string]$WorkspaceRoot = "d:\AFE Git\G3SF\G3FO\server", + [switch]$UseApi, + [string]$SinceTag, + [string]$SinceDate +) + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$CompareScript = Join-Path $ScriptDir "branch_compare.ps1" +$ListScript = Join-Path $ScriptDir "list_orgs_repos.ps1" + +$repos = if ($Repo) { @($Repo) } else { @(& $ListScript -Action list-repos -Org $Org) } +$hasLocal = Test-Path $WorkspaceRoot + +foreach ($r in $repos) { + if (-not $r) { continue } + $path = Join-Path $WorkspaceRoot $r + Write-Host "########## $Org/$r ##########" -ForegroundColor Magenta + if ($UseApi -or (-not $hasLocal) -or (-not (Test-Path $path))) { + $params = @{ Owner = $Org; Repo = $r; BaseBranch = $BaseBranch; HeadBranch = $HeadBranch; CompareSet = $CompareSet } + & $CompareScript @params + } else { + $params = @{ RepoPath = $path; BaseBranch = $BaseBranch; HeadBranch = $HeadBranch; CompareSet = $CompareSet } + if ($SinceTag) { $params.SinceTag = $SinceTag } + if ($SinceDate) { $params.SinceDate = $SinceDate } + & $CompareScript @params + } + Write-Host "" +}