db025cce01
Co-authored-by: Copilot <copilot@github.com>
581 lines
24 KiB
C#
581 lines
24 KiB
C#
using ControlPlane.Core.Models;
|
||
using LibGit2Sharp;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.Logging;
|
||
using System.Text.Json;
|
||
|
||
namespace ControlPlane.Api.Services;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public class PromotionService(IConfiguration config, ILogger<PromotionService> 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);
|
||
|
||
// ── Branch status ────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public Task<List<BranchStatus>> GetLadderStatusAsync(string repoName = "Clarity", CancellationToken ct = default) =>
|
||
Task.Run(() => GetLadderStatusCore(repoName, ct), ct);
|
||
|
||
private List<BranchStatus> 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 = 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 ladder status failed — continuing with cached refs");
|
||
}
|
||
|
||
var result = new List<BranchStatus>();
|
||
|
||
for (var i = 0; i < Ladder.Length; i++)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
var branchName = Ladder[i];
|
||
var branch = repo.Branches[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[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));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ── Promotion ────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Merges <paramref name="from"/> into <paramref name="to"/> with a no-fast-forward merge commit,
|
||
/// then pushes. HEAD is never mutated — the working tree stays on develop throughout.
|
||
/// Streams progress lines to <paramref name="onLine"/>.
|
||
/// </summary>
|
||
public async Task<PromotionRequest> PromoteAsync(
|
||
string from,
|
||
string to,
|
||
string requestedBy,
|
||
string? note,
|
||
Action<string> 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<string> 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 = repo.Network.Remotes["origin"]
|
||
?? throw new InvalidOperationException("No 'origin' remote configured.");
|
||
var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList();
|
||
repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions());
|
||
|
||
// 2. Resolve local branches
|
||
var fromBranch = repo.Branches[from]
|
||
?? throw new InvalidOperationException($"Branch '{from}' not found.");
|
||
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) ────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Force-resets <paramref name="branchName"/> to <paramref name="toSha"/> and force-pushes to origin.
|
||
/// Used to recover a downstream branch that has drifted from trunk (e.g. after an accidental merge commit).
|
||
/// </summary>
|
||
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<Commit>(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 = repo.Network.Remotes["origin"]
|
||
?? throw new InvalidOperationException("No 'origin' remote.");
|
||
// 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) ──────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Cherry-picks the specified commits from <paramref name="from"/> onto <paramref name="to"/>
|
||
/// 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.
|
||
/// </summary>
|
||
public async Task<PromotionRequest> CherryPickAsync(
|
||
string[] shas,
|
||
string from,
|
||
string to,
|
||
string requestedBy,
|
||
string? note,
|
||
Action<string> 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<string> 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 = repo.Network.Remotes["origin"]
|
||
?? throw new InvalidOperationException("No 'origin' remote configured.");
|
||
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<Commit>(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<Commit>(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<Commit>(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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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<List<PromotionRequest>> GetHistoryAsync()
|
||
{
|
||
await _fileLock.WaitAsync();
|
||
try { return LoadHistory(); }
|
||
finally { _fileLock.Release(); }
|
||
}
|
||
|
||
private List<PromotionRequest> LoadHistory()
|
||
{
|
||
if (!File.Exists(HistoryPath)) return [];
|
||
try { return JsonSerializer.Deserialize<List<PromotionRequest>>(File.ReadAllText(HistoryPath), JsonOpts) ?? []; }
|
||
catch { return []; }
|
||
}
|
||
}
|
||
|
||
/// <summary>A single unreleased commit — carries full SHA for cherry-pick operations.</summary>
|
||
public record CommitInfo(string Sha, string ShortSha, string Message, string Author, string Date);
|
||
|
||
/// <summary>Current status of a single branch in the promotion ladder.</summary>
|
||
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
|
||
);
|