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 private static IResult GetLog( IConfiguration config, string? grep = null, int limit = 50) { var repoPath = ResolveRepo(config); if (repoPath is null) return Results.Problem("Could not locate a git repository. Set Git:RepoRoot in appsettings."); using var repo = new Repository(repoPath); var tips = repo.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)repo.Head, }; IEnumerable query = repo.Commits.QueryBy(filter); if (!string.IsNullOrWhiteSpace(grep)) query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase)); var commits = query .Take(limit) .Select(c => ToGitCommit(repo, c)) .ToList(); return Results.Ok(commits); } // GET /api/git/commits/{hash} private static IResult GetCommit(string hash, 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 commit = repo.Lookup(hash); if (commit is null) return Results.NotFound(); 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, }); } // 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 // Returns each local branch and whether it contains ALL of the given commits. private static IResult GetBranchCoverage(IConfiguration config, string? commits = 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 = ResolveRepo(config); if (repoPath is null) return Results.Problem("Could not locate a git repository."); using var repo = new Repository(repoPath); var targetCommits = hashes .Select(h => repo.Lookup(h)) .Where(c => c is not null) .ToList(); if (targetCommits.Count == 0) return Results.Ok(Array.Empty()); var result = repo.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 = repo.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 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 ); } }