181 lines
7.1 KiB
C#
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 */ }
|
|
}
|
|
}
|