| File: tests\Shared\TemporaryWorkspaceGitExtensions.cs | Web Access |
| Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel; using System.Diagnostics; using System.Text; using Xunit; namespace Aspire.Tests.Utils; public static class TemporaryWorkspaceGitExtensions { public static async Task InitializeGitAsync(this TemporaryWorkspace workspace, CancellationToken cancellationToken = default) { workspace.TestOutputHelper.WriteLine($"Initializing git repository at: {workspace.Path}"); await RunGitAsync(workspace.Path, workspace.TestOutputHelper, ["init"], cancellationToken); } internal static async Task RunGitAsync(string workingDirectory, ITestOutputHelper outputHelper, string[] arguments, CancellationToken cancellationToken) { var command = $"git {string.Join(' ', arguments)}"; var stopwatch = Stopwatch.StartNew(); outputHelper.WriteLine($"[{DateTimeOffset.UtcNow:O}] Starting '{command}' in '{workingDirectory}'"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, TestContext.Current.CancellationToken, timeout.Token); using var process = new Process { StartInfo = new ProcessStartInfo("git", arguments) { WorkingDirectory = workingDirectory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } }; try { process.Start(); } catch (Win32Exception ex) when (ex.NativeErrorCode == 2 && Directory.Exists(workingDirectory)) { outputHelper.WriteLine($"Failed to start git: {ex}"); Assert.Skip("git is required for this test but was not found on PATH."); } var stdout = new StringBuilder(); var stderr = new StringBuilder(); try { outputHelper.WriteLine($"[{DateTimeOffset.UtcNow:O}] '{command}' started with PID {process.Id} after {stopwatch.Elapsed}"); // Drain both pipes while Git runs: waiting for exit first can deadlock if either // pipe fills. Log each line immediately so output survives a timeout. var stdoutTask = ReadOutputAsync(process.StandardOutput, stdout, "stdout"); var stderrTask = ReadOutputAsync(process.StandardError, stderr, "stderr"); await Task.WhenAll(process.WaitForExitAsync(cancellation.Token), stdoutTask, stderrTask); } catch (Exception ex) { cancellation.Cancel(); // Disposing Process does not stop it. Request tree termination and reap the root // on any post-start failure, before logging or workspace disposal. HasExited can // change between the check and Kill. Exception? terminationRace = null; try { if (!process.HasExited) { process.Kill(entireProcessTree: true); } } catch (Exception cleanupException) when ((cleanupException is InvalidOperationException or Win32Exception) && process.HasExited) { terminationRace = cleanupException; } // WaitForExitAsync observes only the root, not every descendant. Tree cleanup is // best-effort; strict containment would require process groups or Windows jobs. // https://learn.microsoft.com/dotnet/api/system.diagnostics.process.kill#remarks await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); if (terminationRace is not null) { outputHelper.WriteLine($"[{DateTimeOffset.UtcNow:O}] '{command}' (PID {process.Id}) exited during termination: {terminationRace.Message}"); } if (ex is OperationCanceledException) { outputHelper.WriteLine($"[{DateTimeOffset.UtcNow:O}] '{command}' (PID {process.Id}) {(timeout.IsCancellationRequested ? "timed out" : "was canceled")} after {stopwatch.Elapsed}"); if (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested && !TestContext.Current.CancellationToken.IsCancellationRequested) { throw new TimeoutException($"'{command}' in '{workingDirectory}' (PID {process.Id}) timed out after 30 seconds."); } } else { outputHelper.WriteLine($"[{DateTimeOffset.UtcNow:O}] '{command}' (PID {process.Id}) failed after {stopwatch.Elapsed}: {ex}"); } throw; } finally { outputHelper.WriteLine($"[{DateTimeOffset.UtcNow:O}] '{command}' (PID {process.Id}) finished after {stopwatch.Elapsed}; exit code: {(process.HasExited ? process.ExitCode.ToString() : "still running")}"); } if (process.ExitCode != 0) { throw new InvalidOperationException($"'{command}' in '{workingDirectory}' failed with exit code {process.ExitCode}. stdout: {stdout}, stderr: {stderr}"); } async Task ReadOutputAsync(StreamReader reader, StringBuilder capturedOutput, string streamName) { try { while (await reader.ReadLineAsync(cancellation.Token) is { } line) { capturedOutput.AppendLine(line); outputHelper.WriteLine($"[{DateTimeOffset.UtcNow:O}] '{command}' (PID {process.Id}) {streamName}: {line}"); } outputHelper.WriteLine($"[{DateTimeOffset.UtcNow:O}] '{command}' (PID {process.Id}) {streamName} closed"); } catch { // WhenAll waits for every task. Cancel the other reader and exit wait so a // reader fault reaches process cleanup immediately, not at the timeout. cancellation.Cancel(); throw; } } } }