using ControlPlane.Core.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Diagnostics; using System.Text.Json; namespace ControlPlane.Api.Services; /// /// Handles all git operations for the promotion workflow: /// branch status, diff summaries, merge + push, and promotion history persistence. /// All git commands run against the repo root configured in Docker:RepoRoot. /// public class PromotionService(IConfiguration config, ILogger logger) { // The ordered promotion ladder — each step is a valid promotion. public static readonly string[] Ladder = ["develop", "staging", "uat", "master"]; private string RepoRoot => config["Docker:RepoRoot"] ?? 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() }, }; // ── Branch status ──────────────────────────────────────────────────────── /// /// Returns status for all ladder branches: last commit info + ahead/behind counts vs next branch. /// public async Task> GetLadderStatusAsync(CancellationToken ct = default) { var result = new List(); // Fetch to get up-to-date remote state, but don't fail if we're offline await RunGitAsync("fetch --all --quiet", ct, swallowErrors: true); foreach (var branch in Ladder) { var exists = await BranchExistsAsync(branch, ct); if (!exists) { result.Add(new BranchStatus(branch, false, null, null, 0, 0, [])); continue; } // Last commit on this branch var lastCommit = await GitOutputAsync($"log {branch} -1 --format=%h|%an|%ad|%s --date=short", ct); string? shortHash = null, author = null, date = null, subject = null; if (!string.IsNullOrWhiteSpace(lastCommit)) { var p = lastCommit.Trim().Split('|', 4); if (p.Length == 4) (shortHash, author, date, subject) = (p[0], p[1], p[2], p[3]); } // Ahead/behind vs the NEXT branch in the ladder int ahead = 0, behind = 0; var nextIdx = Array.IndexOf(Ladder, branch) + 1; if (nextIdx < Ladder.Length) { var next = Ladder[nextIdx]; if (await BranchExistsAsync(next, ct)) { var counts = await GitOutputAsync($"rev-list --left-right --count {next}...{branch}", ct); if (!string.IsNullOrWhiteSpace(counts)) { var parts = counts.Trim().Split('\t'); if (parts.Length == 2) { int.TryParse(parts[0], out behind); int.TryParse(parts[1], out ahead); } } } } // Unreleased commit summaries (commits in this branch not yet in next) string[] unreleasedLines = []; if (ahead > 0 && nextIdx < Ladder.Length && await BranchExistsAsync(Ladder[nextIdx], ct)) { var log = await GitOutputAsync($"log {Ladder[nextIdx]}..{branch} --oneline --no-decorate", ct); unreleasedLines = log.Split('\n', StringSplitOptions.RemoveEmptyEntries); } result.Add(new BranchStatus(branch, true, shortHash, $"{author} · {date} · {subject}", ahead, behind, unreleasedLines)); } return result; } // ── Promotion ──────────────────────────────────────────────────────────── /// /// Merges into with a no-fast-forward merge commit, /// then pushes. Streams progress lines to . /// public async Task PromoteAsync( string from, string to, string requestedBy, string? note, Action onLine, CancellationToken ct) { 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 { Log($"▶ Promoting {from} → {to}"); if (!string.IsNullOrWhiteSpace(note)) Log($" Note: {note}"); Log("──────────────────────────────────────"); // 1. Fetch latest Log(" git fetch --all"); await RunGitAsync("fetch --all --quiet", ct); // 2. Checkout target branch Log($" git checkout {to}"); await RunGitAsync($"checkout {to}", ct); // 3. Pull target to latest Log($" git pull origin {to}"); await RunGitAsync($"pull origin {to} --quiet", ct); // 4. Count commits being promoted var logOutput = await GitOutputAsync($"log {to}..{from} --oneline --no-decorate", ct); var commitLines = logOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries); req.CommitCount = commitLines.Length; req.CommitLines = commitLines; Log($" Merging {commitLines.Length} commit(s) from {from}:"); foreach (var cl in commitLines) Log($" {cl}"); // 5. Merge with --no-ff for a clean promotion commit var mergeMsg = $"chore: promote {from} → {to}" + (note != null ? $" — {note}" : ""); Log($" git merge --no-ff {from}"); await RunGitAsync($"merge --no-ff {from} -m \"{mergeMsg}\"", ct); // 6. Push Log($" git push origin {to}"); await RunGitAsync($"push origin {to}", ct); // 7. Return to develop so the working tree stays clean await RunGitAsync("checkout develop", ct, swallowErrors: true); Log("──────────────────────────────────────"); Log($"✔ {from} → {to} promoted successfully at {DateTimeOffset.UtcNow:u}"); req.Status = PromotionStatus.Succeeded; req.CompletedAt = DateTimeOffset.UtcNow; } catch (Exception ex) { Log($"✖ Promotion failed: {ex.Message}"); req.Status = PromotionStatus.Failed; req.CompletedAt = DateTimeOffset.UtcNow; // Try to abort any broken merge state await RunGitAsync("merge --abort", ct, swallowErrors: true); await RunGitAsync("checkout develop", ct, swallowErrors: true); logger.LogError(ex, "Promotion {From}→{To} failed", from, to); } finally { await SaveAsync(req); _lock.Release(); } return req; } // ── 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 []; } } // ── Git helpers ────────────────────────────────────────────────────────── private async Task BranchExistsAsync(string branch, CancellationToken ct) { var output = await GitOutputAsync($"branch --list {branch}", ct); return !string.IsNullOrWhiteSpace(output); } private async Task GitOutputAsync(string args, CancellationToken ct) { var psi = MakePsi(args); using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git"); var output = await proc.StandardOutput.ReadToEndAsync(ct); await proc.WaitForExitAsync(ct); return output; } private async Task RunGitAsync(string args, CancellationToken ct, bool swallowErrors = false) { var psi = MakePsi(args); using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git"); var stderr = await proc.StandardError.ReadToEndAsync(ct); await proc.WaitForExitAsync(ct); if (!swallowErrors && proc.ExitCode != 0) throw new InvalidOperationException($"git {args} exited {proc.ExitCode}: {stderr.Trim()}"); logger.LogDebug("git {Args} → exit {Code}", args, proc.ExitCode); } private ProcessStartInfo MakePsi(string args) => new("git", args) { WorkingDirectory = RepoRoot, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; } /// 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 (shouldn't happen in clean flow) string[] UnreleasedLines // oneline log of the ahead commits );