using System.Diagnostics; namespace w4c_workflows.Services; /// A workflow definition file discovered in the git source. public sealed record WorkflowFile(string Path); /// Current git state of the workflow source (for "sync status vs git"). public sealed record WorkflowSourceState(string? HeadSha, bool Dirty); /// /// Reads workflow YAML definitions from the authoritative git source. v1 uses /// the same local monorepo checkout the Source Code explorer reads /// (SourceCode:RepoRoot); a Forgejo API transport can be swapped in /// behind this interface later. /// public interface IWorkflowSource { Task> ListAsync(CancellationToken ct); Task ReadAsync(string path, CancellationToken ct); WorkflowSourceState GetState(); } public class LocalRepoWorkflowSource : IWorkflowSource { private const string WorkflowsDir = "workflows"; private readonly string _repoRoot; private readonly ILogger _logger; public LocalRepoWorkflowSource(IConfiguration config, IWebHostEnvironment env, ILogger logger) { _logger = logger; _repoRoot = ResolveRepoRoot(config, env); _logger.LogInformation("Workflow source repo root resolved to {Root}", _repoRoot); } public Task> ListAsync(CancellationToken ct) { ct.ThrowIfCancellationRequested(); var dir = Path.Combine(_repoRoot, WorkflowsDir); var files = new List(); if (!Directory.Exists(dir)) return Task.FromResult>(files); foreach (var full in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) { var ext = Path.GetExtension(full); if (ext is not (".yaml" or ".yml")) continue; var rel = Path.GetRelativePath(_repoRoot, full).Replace(Path.DirectorySeparatorChar, '/'); files.Add(new WorkflowFile(rel)); } return Task.FromResult>(files.OrderBy(f => f.Path, StringComparer.Ordinal).ToList()); } public async Task ReadAsync(string path, CancellationToken ct) => await File.ReadAllTextAsync(Path.Combine(_repoRoot, path.Replace('/', Path.DirectorySeparatorChar)), ct); public WorkflowSourceState GetState() { var head = RunGit("rev-parse", "HEAD"); var status = RunGit("status", "--porcelain"); return new WorkflowSourceState( string.IsNullOrWhiteSpace(head) ? null : head, !string.IsNullOrEmpty(status)); } private string? RunGit(params string[] args) { try { var psi = new ProcessStartInfo("git") { WorkingDirectory = _repoRoot, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var arg in args) psi.ArgumentList.Add(arg); using var process = Process.Start(psi); if (process == null) return null; var output = process.StandardOutput.ReadToEnd(); if (!process.WaitForExit(5000)) { process.Kill(entireProcessTree: true); return null; } return process.ExitCode == 0 ? output.Trim() : null; } catch (Exception ex) { _logger.LogDebug(ex, "git invocation failed"); return null; } } private static string ResolveRepoRoot(IConfiguration config, IWebHostEnvironment env) { var configured = config["SourceCode:RepoRoot"]; if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured)) return Path.GetFullPath(configured); var start = AppContext.BaseDirectory; if (!string.IsNullOrEmpty(env.ContentRootPath) && Directory.Exists(env.ContentRootPath)) start = env.ContentRootPath; var walk = Path.GetFullPath(start); while (true) { if (Directory.Exists(Path.Combine(walk, ".git"))) return Path.GetFullPath(walk); var parent = Directory.GetParent(walk)?.FullName; if (string.IsNullOrEmpty(parent) || parent == walk) break; walk = parent; } return Path.GetFullPath(Directory.GetCurrentDirectory()); } }