From b26cc1c0b6545fe193193c7b05b71484f02dbba0 Mon Sep 17 00:00:00 2001
From: amadzarak
Date: Sun, 26 Apr 2026 00:38:10 -0400
Subject: [PATCH] OPC # 0006: OPC Git Trunk-Based management
Co-authored-by: Copilot
---
.../Endpoints/PromotionEndpoints.cs | 39 ++
ControlPlane.Api/Services/PromotionService.cs | 153 +++++++
ControlPlane.Core/Models/ConformanceModels.cs | 28 ++
.../src/api/provisioningApi.ts | 46 ++
clarity.controlplane/src/pages/BranchPage.tsx | 412 +++++++++++++++++-
5 files changed, 664 insertions(+), 14 deletions(-)
create mode 100644 ControlPlane.Core/Models/ConformanceModels.cs
diff --git a/ControlPlane.Api/Endpoints/PromotionEndpoints.cs b/ControlPlane.Api/Endpoints/PromotionEndpoints.cs
index 0414469..7084693 100644
--- a/ControlPlane.Api/Endpoints/PromotionEndpoints.cs
+++ b/ControlPlane.Api/Endpoints/PromotionEndpoints.cs
@@ -140,6 +140,44 @@ public static class PromotionEndpoints
await ctx.Response.Body.FlushAsync(ct);
});
+ // GET /api/promotions/conformance?repo=Clarity
+ // Returns a full TBD conformance report: which branches are diverged, missing, or stale.
+ g.MapGet("/conformance", async (PromotionService svc, CancellationToken ct, string repo = "Clarity") =>
+ Results.Ok(await svc.GetConformanceAsync(repo, ct)));
+
+ // GET /api/promotions/conformance/all
+ // Returns conformance reports for all configured repos (Clarity, OPC, Gateway).
+ g.MapGet("/conformance/all", async (PromotionService svc, IConfiguration config, CancellationToken ct) =>
+ {
+ var allRepos = new[] { "Clarity", "OPC", "Gateway" };
+ var configured = allRepos
+ .Where(r => !string.IsNullOrWhiteSpace(config[$"Git:Repos:{r}"]))
+ .ToArray();
+
+ var tasks = configured.Select(r => svc.GetConformanceAsync(r, ct));
+ var results = await Task.WhenAll(tasks);
+ return Results.Ok(results);
+ });
+
+ // POST /api/promotions/create-branch — body: { branch, fromSha, repo }
+ // Creates a missing ladder branch at the given SHA and pushes to origin.
+ g.MapPost("/create-branch", async (PromotionService svc, CreateLadderBranchRequest req, CancellationToken ct) =>
+ {
+ var allowed = new[] { "staging", "uat", "main" };
+ if (!allowed.Contains(req.Branch))
+ return Results.BadRequest(new { error = $"Create-branch is only allowed for: {string.Join(", ", allowed)}." });
+
+ try
+ {
+ await svc.CreateBranchAsync(req.Branch, req.FromSha, req.Repo ?? "Clarity", ct);
+ return Results.Ok(new { created = req.Branch, fromSha = req.FromSha });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+ });
+
return app;
}
}
@@ -147,3 +185,4 @@ public static class PromotionEndpoints
public record PromoteRequest(string From, string To, string? RequestedBy, string? Note, string? Repo);
public record ResetBranchRequest(string Branch, string ToSha, string? Repo);
public record CherryPickRequest(string[] Shas, string From, string To, string? RequestedBy, string? Note, string? Repo);
+public record CreateLadderBranchRequest(string Branch, string FromSha, string? Repo);
diff --git a/ControlPlane.Api/Services/PromotionService.cs b/ControlPlane.Api/Services/PromotionService.cs
index 151a5e7..cfa0fa7 100644
--- a/ControlPlane.Api/Services/PromotionService.cs
+++ b/ControlPlane.Api/Services/PromotionService.cs
@@ -521,6 +521,159 @@ public class PromotionService(IConfiguration config, ILogger l
}
}
+ // ── Conformance check ────────────────────────────────────────────────────
+
+ ///
+ /// Evaluates whether all branches in the TBD ladder are in conformance:
+ /// develop → staging → uat → main must form a strict linear ancestry chain with no divergence.
+ ///
+ public Task GetConformanceAsync(string repoName = "Clarity", CancellationToken ct = default) =>
+ Task.Run(() => GetConformanceCore(repoName, ct), ct);
+
+ private ConformanceReport GetConformanceCore(string repoName, CancellationToken ct)
+ {
+ var repoPath = GetRepoPath(repoName);
+ var checks = new List();
+
+ if (string.IsNullOrWhiteSpace(repoPath) || !Directory.Exists(repoPath))
+ {
+ foreach (var b in Ladder)
+ checks.Add(new BranchConformanceCheck(b, null, ConformanceViolation.Missing, ConformanceSeverity.Critical,
+ $"Repository '{repoName}' is not configured or the path does not exist.", 0, 0, null));
+ return new ConformanceReport(repoName, false, checks.ToArray());
+ }
+
+ using var repo = new Repository(repoPath);
+
+ // Fetch latest remote refs — swallow network errors so status still works offline.
+ try
+ {
+ var remote = repo.Network.Remotes["origin"];
+ if (remote is not null)
+ {
+ var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList();
+ repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions());
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Fetch during conformance check failed — continuing with cached refs");
+ }
+
+ for (var i = 0; i < Ladder.Length; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var branchName = Ladder[i];
+ var srcName = i > 0 ? Ladder[i - 1] : null; // predecessor branch (e.g. develop for staging)
+ var branch = repo.Branches[branchName];
+
+ // ── Branch missing ──────────────────────────────────────────────
+ if (branch?.Tip is null)
+ {
+ var srcTip = srcName is not null ? repo.Branches[srcName]?.Tip?.Sha : null;
+ checks.Add(new BranchConformanceCheck(
+ branchName, srcName,
+ ConformanceViolation.Missing,
+ srcName is null ? ConformanceSeverity.Critical : ConformanceSeverity.Info,
+ srcName is not null
+ ? $"Branch '{branchName}' does not exist. It should be created from '{srcName}'."
+ : $"Trunk branch '{branchName}' does not exist — the repository may be empty.",
+ 0, 0, srcTip));
+ continue;
+ }
+
+ // ── Trunk (develop) — just needs to exist ───────────────────────
+ if (srcName is null)
+ {
+ checks.Add(new BranchConformanceCheck(
+ branchName, null, ConformanceViolation.OK, ConformanceSeverity.OK,
+ $"Trunk branch '{branchName}' exists.", 0, 0, null));
+ continue;
+ }
+
+ var srcBranch = repo.Branches[srcName];
+ if (srcBranch?.Tip is null)
+ {
+ // Source branch is itself missing — skip, it will be reported separately.
+ checks.Add(new BranchConformanceCheck(
+ branchName, srcName, ConformanceViolation.OK, ConformanceSeverity.OK,
+ $"Source branch '{srcName}' is missing — check skipped.", 0, 0, null));
+ continue;
+ }
+
+ // CalculateHistoryDivergence(srcTip, branchTip):
+ // AheadBy = commits srcBranch has that branch doesn't → branch is pending promotion (stale)
+ // BehindBy = commits branch has that srcBranch doesn't → branch is DIVERGED (violation)
+ var div = repo.ObjectDatabase.CalculateHistoryDivergence(srcBranch.Tip, branch.Tip);
+ var ahead = div.AheadBy ?? 0;
+ var behind = div.BehindBy ?? 0;
+
+ if (behind > 0)
+ {
+ // Downstream has commits the upstream doesn't — TBD violation (broken linear history).
+ checks.Add(new BranchConformanceCheck(
+ branchName, srcName,
+ ConformanceViolation.Diverged, ConformanceSeverity.Critical,
+ $"'{branchName}' has {behind} commit(s) not reachable from '{srcName}'. " +
+ $"This breaks TBD linear history. Likely caused by a commit made directly to '{branchName}' " +
+ $"without backporting to trunk. Fix: reset '{branchName}' to '{srcName}' tip.",
+ behind, ahead,
+ srcBranch.Tip.Sha));
+ }
+ else if (ahead > 0)
+ {
+ // Upstream has unreleased commits — normal TBD state, but flag if count is high.
+ var sev = ahead > 10 ? ConformanceSeverity.Warning : ConformanceSeverity.Info;
+ checks.Add(new BranchConformanceCheck(
+ branchName, srcName,
+ ConformanceViolation.Stale, sev,
+ $"'{branchName}' is {ahead} commit(s) behind '{srcName}'. " +
+ (ahead > 10 ? "Large backlog — consider promoting soon." : "Pending promotion."),
+ 0, ahead, null));
+ }
+ else
+ {
+ checks.Add(new BranchConformanceCheck(
+ branchName, srcName, ConformanceViolation.OK, ConformanceSeverity.OK,
+ $"'{branchName}' is fully in sync with '{srcName}'.", 0, 0, null));
+ }
+ }
+
+ var isConformant = !checks.Any(c =>
+ c.Violation is ConformanceViolation.Diverged or ConformanceViolation.Missing);
+
+ return new ConformanceReport(repoName, isConformant, checks.ToArray());
+ }
+
+ // ── Create branch ─────────────────────────────────────────────────────────
+
+ ///
+ /// Creates a new branch at the given commit SHA and pushes it to origin.
+ /// Used to create missing ladder branches (e.g. staging, uat) from their source branch tip.
+ ///
+ public Task CreateBranchAsync(string branchName, string fromSha, string repoName, CancellationToken ct) =>
+ Task.Run(() =>
+ {
+ var repoPath = GetRepoPath(repoName);
+ using var repo = new Repository(repoPath);
+
+ if (repo.Branches[branchName] is not null)
+ throw new InvalidOperationException($"Branch '{branchName}' already exists in {repoName}.");
+
+ var commit = repo.Lookup(fromSha)
+ ?? throw new InvalidOperationException($"SHA '{fromSha}' not found in {repoName}.");
+
+ repo.Refs.Add($"refs/heads/{branchName}", commit.Sha);
+
+ var remote = repo.Network.Remotes["origin"]
+ ?? throw new InvalidOperationException("No 'origin' remote configured.");
+
+ repo.Network.Push(remote, $"refs/heads/{branchName}:refs/heads/{branchName}", MakePushOptions());
+
+ logger.LogInformation("Created branch {Branch} at {Sha} in {Repo}", branchName, commit.Sha[..7], repoName);
+ }, ct);
+
// ── History persistence ──────────────────────────────────────────────────
private string HistoryPath
diff --git a/ControlPlane.Core/Models/ConformanceModels.cs b/ControlPlane.Core/Models/ConformanceModels.cs
new file mode 100644
index 0000000..2f07a2e
--- /dev/null
+++ b/ControlPlane.Core/Models/ConformanceModels.cs
@@ -0,0 +1,28 @@
+namespace ControlPlane.Core.Models;
+
+public enum ConformanceViolation { OK, Missing, Diverged, Stale }
+public enum ConformanceSeverity { OK, Info, Warning, Critical }
+
+///
+/// The conformance state of one branch in the TBD ladder relative to its upstream source.
+///
+public record BranchConformanceCheck(
+ string Branch,
+ string? SourceBranch, // the upstream branch this is derived from (null for trunk)
+ ConformanceViolation Violation,
+ ConformanceSeverity Severity,
+ string Detail,
+ int AheadOfSource, // commits this branch has that source doesn't — diverged
+ int BehindSource, // commits source has that this branch doesn't — pending promotion
+ string? FixSha // source tip SHA — used when resetting to fix divergence
+);
+
+///
+/// Full TBD conformance report for a single repository.
+/// IsConformant = no Diverged or Missing violations exist.
+///
+public record ConformanceReport(
+ string Repo,
+ bool IsConformant,
+ BranchConformanceCheck[] Checks
+);
diff --git a/clarity.controlplane/src/api/provisioningApi.ts b/clarity.controlplane/src/api/provisioningApi.ts
index 10280b3..0d2e20f 100644
--- a/clarity.controlplane/src/api/provisioningApi.ts
+++ b/clarity.controlplane/src/api/provisioningApi.ts
@@ -378,3 +378,49 @@ export function triggerCherryPick(
return () => { cancelled = true; controller.abort(); };
}
+
+// -- Branch Conformance API --------------------------------------------------
+
+export type ConformanceViolation = 'OK' | 'Missing' | 'Diverged' | 'Stale';
+export type ConformanceSeverity = 'OK' | 'Info' | 'Warning' | 'Critical';
+
+export interface BranchConformanceCheck {
+ branch: string;
+ sourceBranch: string | null;
+ violation: ConformanceViolation;
+ severity: ConformanceSeverity;
+ detail: string;
+ aheadOfSource: number;
+ behindSource: number;
+ fixSha: string | null;
+}
+
+export interface ConformanceReport {
+ repo: string;
+ isConformant: boolean;
+ checks: BranchConformanceCheck[];
+}
+
+export async function getConformanceReport(repo = 'Clarity'): Promise {
+ const res = await fetch(`${BASE_URL}/api/promotions/conformance?repo=${encodeURIComponent(repo)}`);
+ if (!res.ok) throw new Error(`Failed to get conformance report: ${res.statusText}`);
+ return res.json();
+}
+
+export async function getAllConformanceReports(): Promise {
+ const res = await fetch(`${BASE_URL}/api/promotions/conformance/all`);
+ if (!res.ok) throw new Error(`Failed to get conformance reports: ${res.statusText}`);
+ return res.json();
+}
+
+export async function createLadderBranch(branch: string, fromSha: string, repo: string): Promise {
+ const res = await fetch(`${BASE_URL}/api/promotions/create-branch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ branch, fromSha, repo }),
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error((body as { error?: string }).error ?? res.statusText);
+ }
+}
\ No newline at end of file
diff --git a/clarity.controlplane/src/pages/BranchPage.tsx b/clarity.controlplane/src/pages/BranchPage.tsx
index 3e20267..ec1aef1 100644
--- a/clarity.controlplane/src/pages/BranchPage.tsx
+++ b/clarity.controlplane/src/pages/BranchPage.tsx
@@ -6,8 +6,9 @@ import {
} from '@blueprintjs/core';
import {
getLadderStatus, getPromotionHistory, triggerPromotion, triggerCherryPick,
- getImageBuildHistory, resetBranch,
+ getImageBuildHistory, resetBranch, getAllConformanceReports, createLadderBranch,
type BranchStatus, type CommitInfo, type PromotionRecord, type BuildHistoryRecord,
+ type ConformanceReport, type BranchConformanceCheck,
} from '../api/provisioningApi';
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -601,9 +602,364 @@ function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) {
);
}
+// -- ConformancePage ----------------------------------------------------------
+
+const SEV_INTENT: Record = {
+ OK: Intent.SUCCESS,
+ Info: Intent.PRIMARY,
+ Warning: Intent.WARNING,
+ Critical: Intent.DANGER,
+};
+
+const SEV_ICON: Record = {
+ OK: 'tick-circle',
+ Info: 'info-sign',
+ Warning: 'warning-sign',
+ Critical: 'error',
+};
+
+const VIOLATION_LABEL: Record = {
+ OK: 'Conformant',
+ Missing: 'Missing',
+ Diverged: 'Diverged',
+ Stale: 'Stale',
+};
+
+function ConformanceFixButton({
+ check,
+ repo,
+ onDone,
+}: {
+ check: BranchConformanceCheck;
+ repo: string;
+ onDone: () => void;
+}) {
+ const [running, setRunning] = useState(false);
+ const [error, setError] = useState(null);
+ const [dialog, setDialog] = useState(false);
+
+ const handleFix = async () => {
+ setRunning(true);
+ setError(null);
+ try {
+ if (check.violation === 'Diverged' && check.fixSha) {
+ await resetBranch(check.branch, check.fixSha, repo);
+ } else if (check.violation === 'Missing' && check.fixSha) {
+ await createLadderBranch(check.branch, check.fixSha, repo);
+ }
+ setDialog(false);
+ onDone();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Fix failed');
+ setRunning(false);
+ }
+ };
+
+ if (check.violation === 'OK' || check.violation === 'Stale') return null;
+
+ const isDiverged = check.violation === 'Diverged';
+
+ return (
+ <>
+
- ({ label: r, value: r }))}
- value={repo}
- onValueChange={handleRepoChange}
- small
- />
- load()} loading={loading} title="Refresh" />
+ {subNav === 'ladder' && (
+ ({ label: r, value: r }))}
+ value={repo}
+ onValueChange={handleRepoChange}
+ small
+ />
+ )}
+ {subNav === 'ladder' && (
+ load()} loading={loading} title="Refresh" />
+ )}
- {error && (
-
- {error}
-
- )}
+ {/* Sub-nav */}
+
+ {([
+ { id: 'ladder', label: 'Branch Ladder', icon: 'git-merge' },
+ { id: 'conformance', label: 'Conformance', icon: 'endorsed' },
+ ] as const).map((tab) => (
+ setSubNav(tab.id)}
+ style={{ fontWeight: subNav === tab.id ? 600 : 400 }}
+ />
+ ))}
+
+
+ {subNav === 'conformance' && }
+
+ {subNav === 'ladder' && (
+ <>
+ {error && (
+
+ {error}
+
+ )}
{/* Vertical ladder */}
@@ -770,6 +1152,8 @@ export default function BranchPage() {
onDone={() => { setCherryPickDialog(null); load(); }}
/>
)}
- >
+ >
+ )}
+ >
);
}