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. // branch filters to commits reachable from the named local branch; requires a specific repo (not "all"). // Each result includes a branches[] array listing local branches that contain that commit. private static IResult GetLog( IConfiguration config, string? grep = null, int limit = 25, int page = 1, string repo = "all", string? branch = null) { if (!string.IsNullOrWhiteSpace(branch) && repo == "all") return Results.BadRequest("'branch' filter requires a specific 'repo' parameter."); 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); CommitFilter filter; if (!string.IsNullOrWhiteSpace(branch)) { var branchRef = r.Branches[branch]; if (branchRef?.Tip is null) return Results.BadRequest($"Branch '{branch}' not found in repo '{repoKey}'."); filter = new CommitFilter { SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time, IncludeReachableFrom = branchRef.Tip, }; } else { var tips = r.Branches .Where(b => b.Tip != null) .Select(b => (GitObject)b.Tip) .ToList(); 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 pageEntries = bucket .OrderByDescending(x => x.When) .Skip((page - 1) * limit) .Take(limit) .ToList(); // Annotate each commit with the local branches whose tip is a descendant of that commit. var branchMap = new Dictionary>(StringComparer.Ordinal); foreach (var grp in pageEntries.GroupBy(x => x.RepoKey)) { if (!repos.TryGetValue(grp.Key, out var rPath) || !Directory.Exists(rPath)) continue; using var annotRepo = new Repository(rPath); var localBranches = annotRepo.Branches.Where(b => !b.IsRemote && b.Tip != null).ToList(); foreach (var (_, _, c) in grp) { var target = annotRepo.Lookup(c.Hash); if (target is null) { branchMap[c.Hash] = []; continue; } branchMap[c.Hash] = localBranches .Where(b => annotRepo.ObjectDatabase.FindMergeBase(b.Tip, target)?.Sha == target.Sha) .Select(b => b.FriendlyName) .ToList(); } } var commits = pageEntries.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, branches = branchMap.TryGetValue(x.Commit.Hash, out var br) ? (IReadOnlyList)br : Array.Empty(), }).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?repo=all // repo defaults to "default" (single configured repo). Pass "all" to get branches from all // registered repos. Each result includes repoKey so the caller can group branches by repo. private static IResult GetBranches(IConfiguration config, string repo = "default") { if (repo == "all") { var allRepos = ResolveAllRepos(config); if (allRepos.Count == 0) return Results.Problem("Could not locate any git repositories."); var allBranches = new List(); foreach (var (repoKey, rPath) in allRepos) { if (!Directory.Exists(rPath)) continue; using var gitRepo = new Repository(rPath); allBranches.AddRange(gitRepo.Branches .Where(b => !b.IsRemote && b.Tip != null) .OrderBy(b => b.FriendlyName) .Select(b => (object)new { repoKey, 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, })); } return Results.Ok(allBranches); } var repoPath = repo == "default" ? ResolveRepo(config) : ResolveNamedRepo(config, repo) ?? ResolveRepo(config); if (repoPath is null) return Results.Problem("Could not locate a git repository."); var repoLabel = repo == "default" ? "default" : repo; using var singleRepo = new Repository(repoPath); var branches = singleRepo.Branches .Where(b => !b.IsRemote && b.Tip != null) .OrderBy(b => b.FriendlyName) .Select(b => new { repoKey = repoLabel, 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, }) .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 ); } }