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 { /// /// 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 and check the /// exit code instead of scanning the output text. /// private async Task 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; } /// /// 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. /// 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; } } /// /// 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. /// 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. } } }