using System.Collections.Concurrent; using System.Diagnostics; namespace w4c_workflows.Services; /// Result of a single git invocation. public sealed record GitResult(int ExitCode, string StdOut, string StdErr, bool TimedOut) { /// True when git ran to completion with exit code 0. public bool Succeeded => !TimedOut && ExitCode == 0; /// Trimmed stdout when the call succeeded, otherwise null. public string? TrimmedStdOut => Succeeded ? StdOut.Trim() : null; } /// /// The single entry point for invoking the git CLI. It replaces the four /// copy-pasted synchronous helpers (workflow source, per-tenant source, workflow /// file controller, Forgejo service) that each drained only stdout and blocked on /// WaitForExit(5000): a full stderr pipe could deadlock the request and the /// child was never reaped on timeout. /// /// drains both pipes concurrently, waits asynchronously /// under a wall-clock timeout (and the caller token) and kills the whole process /// tree on timeout/cancellation. adds a short-TTL /// cache so a workflow list does not shell out to git on every request. /// public sealed class GitRunner { private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5); private readonly ILogger _logger; private readonly TimeSpan _stateTtl; private readonly ConcurrentDictionary _stateCache = new(StringComparer.Ordinal); private sealed record CachedState(WorkflowSourceState State, DateTime At); public GitRunner(IConfiguration config, ILogger logger) { _logger = logger; // Cache duration for GetStateAsync. 0 disables caching (always shell out). _stateTtl = int.TryParse(config["WorkflowSource:GitStateCacheSeconds"], out var seconds) ? TimeSpan.FromSeconds(Math.Max(0, seconds)) : TimeSpan.FromSeconds(15); } /// /// Runs git in . Both output pipes are drained /// concurrently, the wait is asynchronous and bounded by /// (default 5s) plus , and the /// process tree is killed if either fires. Failures (missing git, bad working /// directory) are reported as a result with exit code -1 rather than thrown. /// public async Task RunAsync( string workDir, IReadOnlyList args, CancellationToken ct = default, TimeSpan? timeout = null, IReadOnlyDictionary? environment = null) { Process? process = null; try { var psi = new ProcessStartInfo("git") { WorkingDirectory = workDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; foreach (var arg in args) psi.ArgumentList.Add(arg); if (environment != null) foreach (var (key, value) in environment) psi.Environment[key] = value; process = Process.Start(psi); if (process == null) return new GitResult(-1, string.Empty, "git failed to start", TimedOut: false); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(timeout ?? DefaultTimeout); var stdoutTask = process.StandardOutput.ReadToEndAsync(); var stderrTask = process.StandardError.ReadToEndAsync(); try { await process.WaitForExitAsync(linked.Token); } catch (OperationCanceledException) { TryKill(process); // Distinguish a wall-clock timeout (report it) from the caller's // request being aborted (propagate — do not mask cancellation). if (ct.IsCancellationRequested) throw; return new GitResult(-1, Pending(stdoutTask), Pending(stderrTask), TimedOut: true); } return new GitResult(process.ExitCode, await stdoutTask, await stderrTask, TimedOut: false); } catch (Exception ex) { _logger.LogDebug(ex, "git invocation failed in {Dir}", workDir); return new GitResult(-1, string.Empty, ex.Message, TimedOut: false); } finally { process?.Dispose(); } } /// /// HEAD sha + dirty flag for a working tree, computed with a single /// git status --porcelain=v2 --branch call (instead of the previous /// rev-parse HEAD + status --porcelain pair) and cached for /// WorkflowSource:GitStateCacheSeconds (default 15s). /// public async Task GetStateAsync(string workDir, CancellationToken ct = default) { if (_stateTtl > TimeSpan.Zero && _stateCache.TryGetValue(workDir, out var cached) && DateTime.UtcNow - cached.At < _stateTtl) return cached.State; var result = await RunAsync( workDir, new[] { "status", "--porcelain=v2", "--branch", "--untracked-files=normal" }, ct); var state = ParseState(result); _stateCache[workDir] = new CachedState(state, DateTime.UtcNow); return state; } /// Drops the cached state for a working tree (e.g. after a commit/pull). public void InvalidateState(string workDir) => _stateCache.TryRemove(workDir, out _); /// /// Parses git status --porcelain=v2 --branch output into a /// . Lines starting with # are branch /// headers; # branch.oid is the HEAD sha and any other line (changed or /// untracked entry) marks the tree dirty. A failed call yields an empty state. /// public static WorkflowSourceState ParseState(GitResult result) { if (!result.Succeeded) return new WorkflowSourceState(null, false); string? head = null; var dirty = false; foreach (var raw in result.StdOut.Split('\n')) { var line = raw.TrimEnd('\r'); if (line.Length == 0) continue; if (line.StartsWith("# branch.oid ", StringComparison.Ordinal)) head = line["# branch.oid ".Length..].Trim(); else if (line[0] != '#') dirty = true; } // An unborn branch reports the sentinel "(initial)" instead of a sha. if (head is "(initial)" or "") head = null; return new WorkflowSourceState(head, dirty); } private static string Pending(Task task) => task.IsCompletedSuccessfully ? task.Result : string.Empty; private static void TryKill(Process process) { try { process.Kill(entireProcessTree: true); } catch { /* already exited or inaccessible */ } } }