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

148 lines
5.3 KiB
C#

using System.Diagnostics;
using Microsoft.AspNetCore.Hosting;
namespace w4c_workflows.Services;
// Git transport half of the Forgejo workflow repo service: bounded-timeout
// process execution, origin reconciliation and local-config cleanup. The admin
// HTTP API lives in ForgejoWorkflowRepoService.AdminApi.cs; the repo/clone
// lifecycle lives in ForgejoWorkflowRepoService.cs.
public sealed partial class ForgejoWorkflowRepoService
{
/// <summary>
/// Runs git and returns its stdout on success / stderr on failure (null when
/// git could not be run at all). Callers that must distinguish success from
/// failure reliably should use <see cref="RunGitExitAsync"/> and check the
/// exit code instead of scanning the output text.
/// </summary>
private async Task<string?> RunGitAsync(string workDir, CancellationToken ct, params string[] args)
{
var result = await RunGitExitAsync(workDir, ct, args);
if (result == null)
return null;
return result.Value.ExitCode == 0 ? result.Value.Stdout : result.Value.Stderr;
}
/// <summary>
/// Runs git with a hard wall-clock timeout and returns the real exit code plus
/// separately captured stdout/stderr. Default git has no connect/transfer
/// timeout, so a dead or unreachable remote (e.g. a stale Forgejo base URL
/// after the server moved) would otherwise block the calling request forever.
/// On timeout the whole process tree is killed and null is returned.
/// </summary>
private async Task<(int ExitCode, string Stdout, string Stderr)?> RunGitExitAsync(
string workDir, CancellationToken ct, params string[] args)
{
try
{
var psi = new ProcessStartInfo("git")
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
using var process = Process.Start(psi);
if (process == null) return null;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(GitOperationTimeout);
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
// Timed out (or the request was aborted) — kill the process tree so a
// hung git transport can't leak or keep a subsequent request blocked.
TryKill(process);
return null;
}
return (process.ExitCode, await stdoutTask, await stderrTask);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "git invocation failed in {Dir}", workDir);
return null;
}
}
/// <summary>
/// Ensures the clone's remote `origin` points at the currently-configured Forgejo
/// base. Used when a clone already exists but may have been created against an old
/// Forgejo location (e.g. forgejo.wiz4chat.com → localhost). A stale origin makes
/// `git pull` hang against a dead host. Best-effort and non-blocking on failure.
/// </summary>
private async Task ReconcileOriginAsync(string tenantDir, string login, string? repoName, CancellationToken ct)
{
var fullName = RepoFullNameForLogin(login, repoName);
var desired = $"{_forgejoBase}/{fullName}.git";
try
{
var current = await RunGitAsync(tenantDir, ct, "remote", "get-url", "origin");
if (string.IsNullOrWhiteSpace(current))
return;
current = current.Trim();
if (current.Equals(desired, StringComparison.OrdinalIgnoreCase))
return;
_logger.LogInformation(
"Reconciling workflow repo origin {Dir}: {Current} -> {Desired}",
tenantDir, current, desired);
await RunGitAsync(tenantDir, ct, "remote", "set-url", "origin", desired);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not reconcile origin for {Dir}", tenantDir);
}
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
}
catch
{
// Best-effort cleanup.
}
}
private static void UnsetLocalConfig(string repoRoot, string key)
{
try
{
var psi = new ProcessStartInfo("git")
{
WorkingDirectory = repoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("config");
psi.ArgumentList.Add("--local");
psi.ArgumentList.Add("--unset");
psi.ArgumentList.Add(key);
using var p = Process.Start(psi);
p?.WaitForExit(3000);
}
catch
{
// Best-effort cleanup.
}
}
}