feat(git-control): add branch compare (main/uat/prod), org/single repo support
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: git-control
|
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)
|
# 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
|
.\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.
|
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
|
.\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.
|
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 |
|
| **Lock UAT for release** | Lock `uat` branch on specified repos before release freeze |
|
||||||
| **Unlock for merge** | Temporarily allow merge (add user to merge allowlist) |
|
| **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) |
|
| **Audit user access** | List repos a user can access (collaborators + org teams) |
|
||||||
| **Bulk branch lock** | Lock same branch across multiple repos |
|
| **Bulk branch lock** | Lock same branch across multiple repos |
|
||||||
| **Read-only maintenance** | Set repo to read-only during maintenance |
|
| **Read-only maintenance** | Set repo to read-only during maintenance |
|
||||||
|
|||||||
@@ -53,3 +53,11 @@ Auth: `Authorization: token {ApiToken}`
|
|||||||
| Remove member | DELETE | `/teams/{id}/members/{username}` |
|
| Remove member | DELETE | `/teams/{id}/members/{username}` |
|
||||||
|
|
||||||
Team permission: `permission` = "none"|"read"|"write"|"admin"|"owner"
|
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.
|
||||||
|
|||||||
@@ -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 "" }
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user