w4c-workflows-api/Services/GitRunner.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

181 lines
7.1 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
namespace w4c_workflows.Services;
/// <summary>Result of a single <c>git</c> invocation.</summary>
public sealed record GitResult(int ExitCode, string StdOut, string StdErr, bool TimedOut)
{
/// <summary>True when git ran to completion with exit code 0.</summary>
public bool Succeeded => !TimedOut && ExitCode == 0;
/// <summary>Trimmed stdout when the call succeeded, otherwise <c>null</c>.</summary>
public string? TrimmedStdOut => Succeeded ? StdOut.Trim() : null;
}
/// <summary>
/// The single entry point for invoking the <c>git</c> 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
/// <c>WaitForExit(5000)</c>: a full stderr pipe could deadlock the request and the
/// child was never reaped on timeout.
///
/// <see cref="RunAsync"/> drains both pipes concurrently, waits asynchronously
/// under a wall-clock timeout (and the caller token) and kills the whole process
/// tree on timeout/cancellation. <see cref="GetStateAsync"/> adds a short-TTL
/// cache so a workflow list does not shell out to git on every request.
/// </summary>
public sealed class GitRunner
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
private readonly ILogger<GitRunner> _logger;
private readonly TimeSpan _stateTtl;
private readonly ConcurrentDictionary<string, CachedState> _stateCache = new(StringComparer.Ordinal);
private sealed record CachedState(WorkflowSourceState State, DateTime At);
public GitRunner(IConfiguration config, ILogger<GitRunner> 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);
}
/// <summary>
/// Runs git in <paramref name="workDir"/>. Both output pipes are drained
/// concurrently, the wait is asynchronous and bounded by
/// <paramref name="timeout"/> (default 5s) plus <paramref name="ct"/>, 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.
/// </summary>
public async Task<GitResult> RunAsync(
string workDir,
IReadOnlyList<string> args,
CancellationToken ct = default,
TimeSpan? timeout = null,
IReadOnlyDictionary<string, string>? 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();
}
}
/// <summary>
/// HEAD sha + dirty flag for a working tree, computed with a single
/// <c>git status --porcelain=v2 --branch</c> call (instead of the previous
/// <c>rev-parse HEAD</c> + <c>status --porcelain</c> pair) and cached for
/// <c>WorkflowSource:GitStateCacheSeconds</c> (default 15s).
/// </summary>
public async Task<WorkflowSourceState> 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;
}
/// <summary>Drops the cached state for a working tree (e.g. after a commit/pull).</summary>
public void InvalidateState(string workDir) => _stateCache.TryRemove(workDir, out _);
/// <summary>
/// Parses <c>git status --porcelain=v2 --branch</c> output into a
/// <see cref="WorkflowSourceState"/>. Lines starting with <c>#</c> are branch
/// headers; <c># branch.oid</c> is the HEAD sha and any other line (changed or
/// untracked entry) marks the tree dirty. A failed call yields an empty state.
/// </summary>
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<string> task)
=> task.IsCompletedSuccessfully ? task.Result : string.Empty;
private static void TryKill(Process process)
{
try { process.Kill(entireProcessTree: true); }
catch { /* already exited or inaccessible */ }
}
}