using ControlPlane.Core.Models; using LibGit2Sharp; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Text.Json; namespace ControlPlane.Api.Services; /// /// Handles all git operations for the promotion workflow using LibGit2Sharp. /// No git.exe subprocess is ever spawned — all operations run through the managed /// LibGit2Sharp API against the server's authoritative repository clone. /// HEAD is never mutated; merges are performed directly on the object database /// so the working tree always reflects the develop branch. /// public class PromotionService(IConfiguration config, ILogger logger) { // The ordered promotion ladder — develop is trunk, main is production. public static readonly string[] Ladder = ["develop", "staging", "uat", "main"]; private string GetRepoPath(string repoName) => config[$"Git:Repos:{repoName}"] ?? string.Empty; private static readonly SemaphoreSlim _lock = new(1, 1); private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; // ── Credentials ────────────────────────────────────────────────────────── private FetchOptions MakeFetchOptions() => new() { CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials { Username = config["Gitea:Owner"] ?? "git", Password = config["Gitea:Token"] ?? string.Empty, }, }; private PushOptions MakePushOptions() => new() { CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials { Username = config["Gitea:Owner"] ?? "git", Password = config["Gitea:Token"] ?? string.Empty, }, }; private static Signature MakeSig() => new("OPC Control Plane", "opc@clarity.internal", DateTimeOffset.UtcNow); // ── Remote URL (config-driven, never reads .git/config URL) ────────────── /// /// Builds the HTTPS remote URL for a named repo entirely from Gitea config. /// The local clone's .git/config remote URL is irrelevant — this is the authority. /// private string GetRemoteUrl(string repoName) { var baseUrl = (config["Gitea:BaseUrl"] ?? throw new InvalidOperationException("Gitea:BaseUrl is not configured.")).TrimEnd('/'); var owner = config[$"Gitea:Repos:{repoName}:Owner"] ?? config["Gitea:Owner"] ?? throw new InvalidOperationException($"Gitea owner not configured for '{repoName}'."); var repoSlug = config[$"Gitea:Repos:{repoName}:Repo"] ?? repoName; return $"{baseUrl}/{owner}/{repoSlug}.git"; } /// /// Returns the 'origin' remote after normalising its URL to the config-driven HTTPS URL. /// If the clone was checked out with SSH (e.g. on a dev machine), this corrects it silently /// so that LibGit2Sharp — which has no SSH support — always uses HTTPS. /// private Remote EnsureRemote(Repository repo, string repoName) { var url = GetRemoteUrl(repoName); var remote = repo.Network.Remotes["origin"]; if (remote is null) return repo.Network.Remotes.Add("origin", url); if (remote.Url != url) repo.Network.Remotes.Update("origin", r => r.Url = url); return repo.Network.Remotes["origin"]!; } // ── Branch status ──────────────────────────────────────────────────────── /// /// Returns status for all ladder branches: last commit info + ahead/behind counts vs next branch. /// Runs on a thread-pool thread because LibGit2Sharp network operations are synchronous. /// public Task> GetLadderStatusAsync(string repoName = "Clarity", CancellationToken ct = default) => Task.Run(() => GetLadderStatusCore(repoName, ct), ct); private List GetLadderStatusCore(string repoName, CancellationToken ct) { var repoPath = GetRepoPath(repoName); if (string.IsNullOrWhiteSpace(repoPath) || !Directory.Exists(repoPath)) return Ladder.Select(b => new BranchStatus(b, false, null, null, 0, 0, [])).ToList(); using var repo = new Repository(repoPath); // Fetch to get up-to-date remote refs; swallow network errors so status still works offline. try { var remote = EnsureRemote(repo, repoName); var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList(); repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions()); } catch (Exception ex) { logger.LogWarning(ex, "Fetch during ladder status failed — continuing with cached refs"); } var result = new List(); for (var i = 0; i < Ladder.Length; i++) { ct.ThrowIfCancellationRequested(); var branchName = Ladder[i]; // Always read from the remote tracking ref so the status reflects what is on origin, // not the server's potentially-stale local branch pointer. var branch = repo.Branches[$"origin/{branchName}"]; if (branch?.Tip is null) { result.Add(new BranchStatus(branchName, false, null, null, 0, 0, [])); continue; } var tip = branch.Tip; var when = tip.Author.When; var summary = $"{tip.Author.Name} · {when:yyyy-MM-dd} · {tip.MessageShort}"; // Ahead/behind vs the next branch in the ladder int ahead = 0; int behind = 0; CommitInfo[] unreleasedCommits = []; if (i + 1 < Ladder.Length) { var nextBranch = repo.Branches[$"origin/{Ladder[i + 1]}"]; if (nextBranch?.Tip is not null) { var div = repo.ObjectDatabase.CalculateHistoryDivergence(tip, nextBranch.Tip); ahead = div.AheadBy ?? 0; behind = div.BehindBy ?? 0; if (ahead > 0) { unreleasedCommits = repo.Commits .QueryBy(new CommitFilter { IncludeReachableFrom = tip, ExcludeReachableFrom = nextBranch.Tip, SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, }) .Select(c => new CommitInfo( c.Sha, c.Sha[..7], c.MessageShort, c.Author.Name, c.Author.When.ToString("yyyy-MM-dd"))) .ToArray(); } } } result.Add(new BranchStatus(branchName, true, tip.Sha[..7], summary, ahead, behind, unreleasedCommits, tip.Sha)); } return result; } // ── Promotion ──────────────────────────────────────────────────────────── /// /// Merges into with a no-fast-forward merge commit, /// then pushes. HEAD is never mutated — the working tree stays on develop throughout. /// Streams progress lines to . /// public async Task PromoteAsync( string from, string to, string requestedBy, string? note, Action onLine, CancellationToken ct, string repoName = "Clarity") { if (!await _lock.WaitAsync(TimeSpan.Zero, ct)) { var busy = new PromotionRequest { FromBranch = from, ToBranch = to, Status = PromotionStatus.Failed }; busy.Log.Add("⚠️ Another promotion is already in progress."); return busy; } var req = new PromotionRequest { FromBranch = from, ToBranch = to, RequestedBy = requestedBy, Note = note, Status = PromotionStatus.Running, }; void Log(string line) { req.Log.Add(line); onLine(line); } try { await Task.Run(() => PromoteCore(from, to, note, repoName, req, Log, ct), ct); } catch (Exception ex) { Log($"✖ Promotion failed: {ex.Message}"); req.Status = PromotionStatus.Failed; req.CompletedAt = DateTimeOffset.UtcNow; logger.LogError(ex, "Promotion {From}→{To} failed", from, to); } finally { await SaveAsync(req); _lock.Release(); } return req; } private void PromoteCore( string from, string to, string? note, string repoName, PromotionRequest req, Action Log, CancellationToken ct) { Log($"▶ Promoting {from} → {to} [{repoName}]"); if (!string.IsNullOrWhiteSpace(note)) Log($" Note: {note}"); Log("──────────────────────────────────────"); using var repo = new Repository(GetRepoPath(repoName)); // 1. Fetch latest remote state for all branches Log(" Fetching origin..."); var remote = EnsureRemote(repo, repoName); var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList(); repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions()); // 2. Resolve branches — always read from origin/ so we reflect what is actually on the remote, // never the server's potentially-stale local branch pointers. var fromBranch = repo.Branches[$"origin/{from}"] ?? throw new InvalidOperationException($"Remote branch 'origin/{from}' not found."); // `to` is read locally because we need to mutate its ref and push — it is immediately // fast-forwarded to origin/{to} in the next step so it is never stale when used. var toBranch = repo.Branches[to] ?? throw new InvalidOperationException($"Branch '{to}' not found."); // 3. Fast-forward local `to` to its remote tracking branch (equivalent to git pull --ff-only) var remoteTracking = repo.Branches[$"origin/{to}"]; if (remoteTracking?.Tip is not null && toBranch.Tip.Sha != remoteTracking.Tip.Sha) { Log($" Fast-forwarding {to} to origin/{to}..."); repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, remoteTracking.Tip.Sha); toBranch = repo.Branches[to]!; // refresh after update } ct.ThrowIfCancellationRequested(); var fromTip = fromBranch.Tip; var toTip = toBranch.Tip; // 4. Enumerate commits being promoted var pendingCommits = repo.Commits.QueryBy(new CommitFilter { IncludeReachableFrom = fromTip, ExcludeReachableFrom = toTip, SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, }).ToList(); if (pendingCommits.Count == 0) { Log($" ℹ {to} is already up-to-date with {from}. Nothing to promote."); req.Status = PromotionStatus.Succeeded; req.CommitCount = 0; req.CommitLines = []; req.CompletedAt = DateTimeOffset.UtcNow; return; } req.CommitCount = pendingCommits.Count; req.CommitLines = pendingCommits.Select(c => $"{c.Sha[..7]} {c.MessageShort}").ToArray(); Log($" {pendingCommits.Count} commit(s) to promote:"); foreach (var cl in req.CommitLines) Log($" {cl}"); ct.ThrowIfCancellationRequested(); // 5. Safety check: `from` must be a descendant of `to` (fast-forward is only possible // when the target branch has no commits that aren't already reachable from source). // This is the TBD invariant — staging/uat/main are always subsets of develop's linear history. var isAncestor = repo.ObjectDatabase.FindMergeBase(fromTip, toTip)?.Sha == toTip.Sha; if (!isAncestor) { throw new InvalidOperationException( $"'{to}' has commits not in '{from}' — fast-forward is not possible. " + $"This means '{to}' diverged from trunk. " + $"Check whether a hotfix was committed directly to '{to}' without being backported to '{from}'."); } // 6. Fast-forward: advance the local `to` ref to `from`'s tip — no merge commit, linear history. // Equivalent to: git push origin {from}:{to} // HEAD is never mutated, working tree is untouched. var oldToSha = toTip.Sha; repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, fromTip.Sha); Log($" Fast-forward: refs/heads/{to} {oldToSha[..7]} → {fromTip.Sha[..7]}"); ct.ThrowIfCancellationRequested(); // 7. Push to origin; roll back the local ref if push fails so nothing is left half-done Log($" Pushing {to} to origin..."); try { repo.Network.Push(remote, $"refs/heads/{to}:refs/heads/{to}", MakePushOptions()); } catch { repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, oldToSha); throw; } Log("──────────────────────────────────────"); Log($"✔ {from} → {to} promoted successfully ({pendingCommits.Count} commit(s)) at {DateTimeOffset.UtcNow:u}"); req.Status = PromotionStatus.Succeeded; req.CompletedAt = DateTimeOffset.UtcNow; } // ── Branch reset (recovery) ──────────────────────────────────────────────── /// /// Force-resets to and force-pushes to origin. /// Used to recover a downstream branch that has drifted from trunk (e.g. after an accidental merge commit). /// public Task ResetBranchAsync(string branchName, string toSha, string repoName, CancellationToken ct) => Task.Run(() => { var repoPath = GetRepoPath(repoName); using var repo = new Repository(repoPath); var commit = repo.Lookup(toSha) ?? throw new InvalidOperationException($"SHA '{toSha}' not found in {repoName}."); var branch = repo.Branches[branchName] ?? throw new InvalidOperationException($"Branch '{branchName}' not found in {repoName}."); var oldSha = branch.Tip.Sha; repo.Refs.UpdateTarget(branch.Reference.CanonicalName, commit.Sha); try { var remote = EnsureRemote(repo, repoName); // Force push — "+" prefix overrides remote reflog repo.Network.Push(remote, $"+refs/heads/{branchName}:refs/heads/{branchName}", MakePushOptions()); } catch { repo.Refs.UpdateTarget(branch.Reference.CanonicalName, oldSha); throw; } logger.LogInformation("Reset {Branch} from {Old} to {New} in {Repo}", branchName, oldSha[..7], commit.Sha[..7], repoName); }, ct); // ── Cherry-pick (partial promotion) ────────────────────────────────────── /// /// Cherry-picks the specified commits from onto /// and pushes. Unlike a full fast-forward promotion, cherry-pick copies individual commits /// as new commits — useful for promoting a subset of changes to a downstream environment. /// Note: cherry-pick will cause the target branch to diverge from trunk. /// public async Task CherryPickAsync( string[] shas, string from, string to, string requestedBy, string? note, Action onLine, CancellationToken ct, string repoName = "Clarity") { if (!await _lock.WaitAsync(TimeSpan.Zero, ct)) { var busy = new PromotionRequest { FromBranch = from, ToBranch = to, Status = PromotionStatus.Failed }; busy.Log.Add("⚠️ Another promotion or cherry-pick is already in progress."); return busy; } var req = new PromotionRequest { FromBranch = from, ToBranch = to, RequestedBy = requestedBy, Note = note, Status = PromotionStatus.Running, }; void Log(string line) { req.Log.Add(line); onLine(line); } try { await Task.Run(() => CherryPickCore(shas, from, to, repoName, req, Log, ct), ct); } catch (Exception ex) { Log($"✖ Cherry-pick failed: {ex.Message}"); req.Status = PromotionStatus.Failed; req.CompletedAt = DateTimeOffset.UtcNow; logger.LogError(ex, "Cherry-pick {From}→{To} failed", from, to); } finally { await SaveAsync(req); _lock.Release(); } return req; } private void CherryPickCore( string[] shas, string from, string to, string repoName, PromotionRequest req, Action Log, CancellationToken ct) { Log($"▶ Cherry-pick {shas.Length} commit(s): {from} → {to} [{repoName}]"); if (!string.IsNullOrWhiteSpace(req.Note)) Log($" Note: {req.Note}"); Log("──────────────────────────────────────"); using var repo = new Repository(GetRepoPath(repoName)); if (repo.Info.IsBare) throw new InvalidOperationException( "Cherry-pick requires a non-bare repository clone. " + "Ensure Git:Repos:{name} points to a standard (non-bare) clone."); // 1. Fetch Log(" Fetching origin..."); var remote = EnsureRemote(repo, repoName); var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList(); repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions()); // 2. Resolve target branch var toBranch = repo.Branches[to] ?? throw new InvalidOperationException($"Branch '{to}' not found."); // 3. Fast-forward `to` to its remote tracking branch (sync with origin) var remoteTracking = repo.Branches[$"origin/{to}"]; if (remoteTracking?.Tip is not null && toBranch.Tip.Sha != remoteTracking.Tip.Sha) { Log($" Fast-forwarding {to} to origin/{to}..."); repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, remoteTracking.Tip.Sha); toBranch = repo.Branches[to]!; } var savedToSha = toBranch.Tip.Sha; var originalHeadBranchName = repo.Head.FriendlyName; // 4. Resolve commits — shas arrive newest-first from UI (topological order); // reverse so we apply oldest → newest (preserves logical order in history). var commitsOrdered = shas .Select(sha => repo.Lookup(sha) ?? throw new InvalidOperationException($"Commit '{sha}' not found in {repoName}.")) .Reverse() .ToList(); req.CommitCount = commitsOrdered.Count; req.CommitLines = commitsOrdered.Select(c => $"{c.Sha[..7]} {c.MessageShort}").ToArray(); Log($" {commitsOrdered.Count} commit(s) to apply (oldest → newest):"); foreach (var c in commitsOrdered) Log($" {c.Sha[..7]} {c.MessageShort}"); ct.ThrowIfCancellationRequested(); // 5. Force-checkout target branch — discards any dirty working tree state left by a // previous failed cherry-pick or interrupted operation. This is a server-only clone // managed exclusively by the control plane, so force is always safe here. Log($" Checking out {to} (force)..."); var forceCheckout = new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }; Commands.Checkout(repo, toBranch, forceCheckout); try { var sig = MakeSig(); foreach (var commit in commitsOrdered) { ct.ThrowIfCancellationRequested(); Log($" Applying {commit.Sha[..7]} {commit.MessageShort}..."); var result = repo.CherryPick(commit, sig); switch (result.Status) { case CherryPickStatus.CherryPicked: Log($" \u2714 \u2192 {result.Commit!.Sha[..7]}"); break; case CherryPickStatus.Conflicts: Log($" \u2716 Conflict \u2014 aborting and rolling back"); repo.Reset(ResetMode.Hard, repo.Lookup(savedToSha)); throw new InvalidOperationException( $"Cherry-pick conflict on {commit.Sha[..7]}: {commit.MessageShort}. " + "Resolve conflicts manually or promote a different set of commits."); default: Log($" \u2261 Already present or no changes \u2014 skipped"); break; } } // 6. Push Log($" Pushing {to} to origin..."); try { repo.Network.Push(remote, $"refs/heads/{to}:refs/heads/{to}", MakePushOptions()); } catch { repo.Reset(ResetMode.Hard, repo.Lookup(savedToSha)); throw; } Log("──────────────────────────────────────"); Log($"✔ Cherry-picked {commitsOrdered.Count} commit(s) to {to} at {DateTimeOffset.UtcNow:u}"); req.Status = PromotionStatus.Succeeded; req.CompletedAt = DateTimeOffset.UtcNow; } finally { // Always restore HEAD to the original branch regardless of outcome try { var headBranch = repo.Branches[originalHeadBranchName]; if (headBranch is not null) Commands.Checkout(repo, headBranch, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); } catch (Exception ex) { logger.LogWarning(ex, "Could not restore HEAD to '{Branch}' after cherry-pick", originalHeadBranchName); } } } // ── 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 = EnsureRemote(repo, repoName); 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) // Always read from origin/ tracking refs — never local branch pointers. var branch = repo.Branches[$"origin/{branchName}"]; // ── Branch missing ────────────────────────────────────────────── if (branch?.Tip is null) { var srcTip = srcName is not null ? repo.Branches[$"origin/{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[$"origin/{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 = EnsureRemote(repo, repoName); 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 { get { var folder = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"] ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets")); Directory.CreateDirectory(folder); return Path.Combine(folder, "promotions.json"); } } private static readonly SemaphoreSlim _fileLock = new(1, 1); private async Task SaveAsync(PromotionRequest req) { await _fileLock.WaitAsync(); try { var all = LoadHistory(); var idx = all.FindIndex(r => r.Id == req.Id); if (idx >= 0) all[idx] = req; else all.Insert(0, req); if (all.Count > 100) all = all[..100]; await File.WriteAllTextAsync(HistoryPath, JsonSerializer.Serialize(all, JsonOpts)); } finally { _fileLock.Release(); } } public async Task> GetHistoryAsync() { await _fileLock.WaitAsync(); try { return LoadHistory(); } finally { _fileLock.Release(); } } private List LoadHistory() { if (!File.Exists(HistoryPath)) return []; try { return JsonSerializer.Deserialize>(File.ReadAllText(HistoryPath), JsonOpts) ?? []; } catch { return []; } } // ── OPC number extraction ───────────────────────────────────────────── private static readonly System.Text.RegularExpressions.Regex OpcTagPattern = new(@"OPC\s*#\s*(\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); /// /// Scans the most recent commits on and /// returns a distinct, sorted list of OPC numbers referenced in commit messages (e.g. "OPC # 0042"). /// Safe to call when git is not configured — returns an empty list on any error. /// public Task> ExtractOpcNumbersAsync( string repoName = "Clarity", string branch = "main", int limit = 50, CancellationToken ct = default) => Task.Run(() => ExtractOpcNumbersCore(repoName, branch, limit), ct); private List ExtractOpcNumbersCore(string repoName, string branch, int limit) { var repoPath = GetRepoPath(repoName); if (string.IsNullOrWhiteSpace(repoPath) || !Directory.Exists(repoPath)) return []; try { using var repo = new Repository(repoPath); var b = repo.Branches[branch] ?? repo.Branches[$"origin/{branch}"]; if (b is null) return []; var set = new HashSet(StringComparer.Ordinal); foreach (var commit in b.Commits.Take(limit)) foreach (System.Text.RegularExpressions.Match m in OpcTagPattern.Matches(commit.Message)) set.Add($"OPC # {m.Groups[1].Value.PadLeft(4, '0')}"); return [.. set.OrderBy(x => x)]; } catch (Exception ex) { logger.LogWarning(ex, "ExtractOpcNumbers failed for {Repo}/{Branch}", repoName, branch); return []; } } } /// A single unreleased commit — carries full SHA for cherry-pick operations. public record CommitInfo(string Sha, string ShortSha, string Message, string Author, string Date); /// Current status of a single branch in the promotion ladder. public record BranchStatus( string Branch, bool Exists, string? ShortHash, string? LastCommitSummary, int AheadOfNext, // commits this branch has that the next doesn't int BehindNext, // commits next has that this branch doesn't (diverged) CommitInfo[] UnreleasedCommits, // rich commit objects for cherry-pick UI string? TipSha = null // full 40-char SHA for build-gate checks );