using ControlPlane.Core.Models; using LibGit2Sharp; namespace ControlPlane.Api.Endpoints; public static class GitEndpoints { public static IEndpointRouteBuilder MapGitEndpoints(this IEndpointRouteBuilder app) { app.MapGet("/api/git/log", GetLog); app.MapGet("/api/git/commits/{hash}", GetCommit); app.MapGet("/api/git/branches", GetBranches); app.MapGet("/api/git/branch-coverage", GetBranchCoverage); return app; } // GET /api/git/log?grep=OPC+%23+0001&limit=50&repo=all // repo can be "all" (default) or a named key from Git:Repos (e.g. "Clarity", "OPC", "Gateway"). // All responses include a repoKey field so the caller knows which repo each commit came from. // GET /api/git/log?grep=...&limit=25&page=1&repo=all // page is 1-based. Each repo contributes up to limit*page commits before merge-sort+skip/take. private static IResult GetLog( IConfiguration config, string? grep = null, int limit = 25, int page = 1, string repo = "all") { var repos = repo == "all" ? ResolveAllRepos(config) : ResolveNamedRepo(config, repo) is { } p ? new Dictionary { [repo] = p } : null; if (repos is null || repos.Count == 0) return Results.Problem("Could not locate any git repositories. Set Git:Repos in appsettings."); var bucket = new List<(DateTimeOffset When, string RepoKey, GitCommit Commit)>(); foreach (var (repoKey, repoPath) in repos) { if (!Directory.Exists(repoPath)) continue; using var r = new Repository(repoPath); var tips = r.Branches .Where(b => b.Tip != null) .Select(b => (GitObject)b.Tip) .ToList(); var filter = new CommitFilter { SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, IncludeReachableFrom = tips.Count > 0 ? tips : (object)r.Head, }; IEnumerable query = r.Commits.QueryBy(filter); if (!string.IsNullOrWhiteSpace(grep)) query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase)); foreach (var c in query.Take(limit * page)) bucket.Add((c.Author.When, repoKey, ToGitCommit(r, c))); } var commits = bucket .OrderByDescending(x => x.When) .Skip((page - 1) * limit) .Take(limit) .Select(x => new { repoKey = x.RepoKey, hash = x.Commit.Hash, shortHash = x.Commit.ShortHash, author = x.Commit.Author, date = x.Commit.Date, subject = x.Commit.Subject, files = x.Commit.Files, }) .ToList(); return Results.Ok(commits); } // GET /api/git/commits/{hash} // Searches all registered repos — hash may belong to any of the three repos. private static IResult GetCommit(string hash, IConfiguration config) { var repos = ResolveAllRepos(config); if (repos.Count == 0) return Results.Problem("Could not locate any git repositories."); foreach (var (_, repoPath) in repos) { if (!Directory.Exists(repoPath)) continue; using var repo = new Repository(repoPath); var commit = repo.Lookup(hash); if (commit is null) continue; var parentTree = commit.Parents.FirstOrDefault()?.Tree; var changes = repo.Diff.Compare(parentTree, commit.Tree); var patch = repo.Diff.Compare(parentTree, commit.Tree); var files = changes.Select(c => new { path = c.Path, oldPath = c.OldPath, status = c.Status.ToString(), additions = patch[c.Path]?.LinesAdded ?? 0, deletions = patch[c.Path]?.LinesDeleted ?? 0, patch = patch[c.Path]?.Patch ?? string.Empty, }).ToList(); return Results.Ok(new { hash = commit.Sha, shortHash = commit.Sha[..7], author = commit.Author.Name, email = commit.Author.Email, date = commit.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), subject = commit.MessageShort, body = commit.Message, files, }); } return Results.NotFound(); } // GET /api/git/branches private static IResult GetBranches(IConfiguration config) { var repoPath = ResolveRepo(config); if (repoPath is null) return Results.Problem("Could not locate a git repository."); using var repo = new Repository(repoPath); var branches = repo.Branches .Where(b => !b.IsRemote && b.Tip != null) .Select(b => new { name = b.FriendlyName, hash = b.Tip.Sha, shortHash = b.Tip.Sha[..7], subject = b.Tip.MessageShort, author = b.Tip.Author.Name, date = b.Tip.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), isHead = b.IsCurrentRepositoryHead, }) .OrderBy(b => b.name) .ToList(); return Results.Ok(branches); } // GET /api/git/branch-coverage?commits=hash1,hash2,hash3&repo=OPC // repo defaults to the single configured repo. Pass a named key from Git:Repos to // query a specific repo — the backend silently ignores hashes it cannot find. private static IResult GetBranchCoverage(IConfiguration config, string? commits = null, string? repo = null) { if (string.IsNullOrWhiteSpace(commits)) return Results.Ok(Array.Empty()); var hashes = commits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (hashes.Length == 0) return Results.Ok(Array.Empty()); var repoPath = repo is not null ? ResolveNamedRepo(config, repo) ?? ResolveRepo(config) : ResolveRepo(config); if (repoPath is null) return Results.Problem("Could not locate a git repository."); using var r = new Repository(repoPath); var targetCommits = hashes .Select(h => r.Lookup(h)) .Where(c => c is not null) .ToList(); if (targetCommits.Count == 0) return Results.Ok(Array.Empty()); var result = r.Branches .Where(b => !b.IsRemote && b.Tip != null) .Select(b => { var contains = targetCommits.All(tc => { // If merge base of branch tip and target == target, then target is an ancestor var mergeBase = r.ObjectDatabase.FindMergeBase(b.Tip, tc!); return mergeBase?.Sha == tc!.Sha; }); return new { branch = b.FriendlyName, contains, tipHash = b.Tip.Sha[..7], isHead = b.IsCurrentRepositoryHead, }; }) .OrderBy(b => b.branch) .ToList(); return Results.Ok(result); } // ── Helpers ─────────────────────────────────────────────────────────────── /// Resolves a named repo from the Git:Repos registry. private static string? ResolveNamedRepo(IConfiguration config, string repoKey) { var path = config[$"Git:Repos:{repoKey}"]; return !string.IsNullOrWhiteSpace(path) && Directory.Exists(path) ? path : null; } /// Returns all repos from the Git:Repos registry that exist on disk. /// Falls back to the single configured repo if the registry is empty. private static IReadOnlyDictionary ResolveAllRepos(IConfiguration config) { var result = new Dictionary(); foreach (var child in config.GetSection("Git:Repos").GetChildren()) { var path = child.Value; if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) result[child.Key] = path; } if (result.Count == 0 && ResolveRepo(config) is { } single) result["default"] = single; return result; } /// Resolves the repo root: explicit config overrides, otherwise auto-discover /// from the running assembly directory upward via LibGit2Sharp. private static string? ResolveRepo(IConfiguration config) { var configured = config["Git:RepoRoot"] ?? config["Docker:RepoRoot"]; if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured)) return configured; // Auto-discover: walk up from the app's own directory var startPath = AppContext.BaseDirectory; var discovered = Repository.Discover(startPath); if (discovered is null) return null; // Repository.Discover returns the .git directory path; get the working dir using var probe = new Repository(discovered); return probe.Info.WorkingDirectory; } private static GitCommit ToGitCommit(Repository repo, Commit c) { string[] files; try { var parentTree = c.Parents.FirstOrDefault()?.Tree; var changes = repo.Diff.Compare(parentTree, c.Tree); files = changes.Select(ch => ch.Path).ToArray(); } catch { files = []; } return new GitCommit( Hash: c.Sha, ShortHash: c.Sha[..7], Author: c.Author.Name, Date: c.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), Subject: c.MessageShort, Files: files ); } }