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);
// ── 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 = 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();
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, 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 = 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) ────────────────────────────────────────────────
///
/// 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 = 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) ──────────────────────────────────────
///
/// 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 = 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(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 = 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
{
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
);