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 */ }
}
}