File: Git\GitCommandRunner.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Text;
using Aspire.Cli.DotNet;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Git;
 
internal interface IGitCommandRunner
{
    Task<string> RunAsync(DirectoryInfo workingDirectory, string[] arguments, GitCommandMode mode, CancellationToken cancellationToken);
}
 
internal sealed class GitCommandRunner(ICliHostEnvironment hostEnvironment, ILogger<GitCommandRunner> logger) : IGitCommandRunner
{
    private static readonly HashSet<string> s_repositoryEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase)
    {
        "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"
    };
 
    private static readonly HashSet<string> s_worktreeEnvironmentVariables = new(
    [
        "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_SHALLOW_FILE",
        .. Acquisition.IdentityResolver.IdentityEnvVarNames,
        .. ProcessExecutionFactory.InvocationScopedEnvVarNames
    ], StringComparer.OrdinalIgnoreCase);
 
    public async Task<string> RunAsync(DirectoryInfo workingDirectory, string[] arguments, GitCommandMode mode, CancellationToken cancellationToken)
    {
        var startInfo = CreateStartInfo(workingDirectory.FullName, arguments, mode);
        var command = arguments[0].StartsWith("--git-dir=", StringComparison.Ordinal) ? arguments[1] : arguments[0];
        using var scope = logger.BeginScope("Git {Operation} ({OperationId})", command, Guid.NewGuid());
        return await RunProcessAsync(startInfo, captureCancellationOutput: command is "fetch" or "ls-remote" && mode != GitCommandMode.Captured && startInfo.RedirectStandardOutput,
            mode, cancellationToken);
    }
 
    internal ProcessStartInfo CreateStartInfo(string directory, string[] arguments, GitCommandMode mode)
    {
        var interactive = mode == GitCommandMode.Interactive && hostEnvironment.SupportsInteractiveInput;
        var command = arguments[0].StartsWith("--git-dir=", StringComparison.Ordinal) ? arguments[1] : arguments[0];
        var interactiveFetch = command == "fetch" && interactive;
        // Ref resolution needs stdout for object IDs, but authentication instructions on
        // stderr must remain visible before an interactive credential prompt completes.
        var interactiveRemote = interactive && command is "fetch" or "ls-remote";
        var startInfo = new ProcessStartInfo("git", ["--literal-pathspecs", .. arguments])
        {
            WorkingDirectory = directory,
            RedirectStandardInput = mode == GitCommandMode.Captured,
            // Fetch prompts need not end in a newline, and Git must control password echo.
            RedirectStandardOutput = !interactiveFetch,
            RedirectStandardError = !interactiveRemote,
            StandardOutputEncoding = interactiveFetch ? null : Encoding.UTF8,
            StandardErrorEncoding = interactiveRemote ? null : Encoding.UTF8,
            CreateNoWindow = !interactive
        };
        startInfo.Environment["GIT_TERMINAL_PROMPT"] = interactive ? "1" : "0";
        if (!interactive && mode != GitCommandMode.Captured)
        {
            // GIT_TERMINAL_PROMPT does not govern GCM UI. Preserve configured SSH launchers.
            // https://github.com/git-ecosystem/git-credential-manager/blob/release/docs/environment.md#gcm_interactive
            startInfo.Environment["GCM_INTERACTIVE"] = "never";
        }
        startInfo.Environment["LC_ALL"] = "C";
        FilterEnvironment(startInfo.Environment, mode);
 
        return startInfo;
    }
 
    internal static void FilterEnvironment(IDictionary<string, string?> environment, GitCommandMode mode)
    {
        if (mode != GitCommandMode.Captured)
        {
            // Removing known names preserves the environment dictionary's platform-specific casing.
            foreach (var key in s_repositoryEnvironmentVariables)
            {
                environment.Remove(key);
            }
            return;
        }
 
        foreach (var key in environment.Keys.ToArray())
        {
            if (s_repositoryEnvironmentVariables.Contains(key) || s_worktreeEnvironmentVariables.Contains(key))
            {
                environment.Remove(key);
            }
        }
    }
 
    internal async Task<string> RunProcessAsync(ProcessStartInfo startInfo, bool captureCancellationOutput, GitCommandMode mode, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var stopwatch = Stopwatch.StartNew();
        // Arguments, directories, captured content and exception messages can expose credentials.
        // Log lifecycle metadata only; the generic capture runner must keep its diagnostics off.
        logger.LogDebug("Starting Git process.");
        using var registration = cancellationToken.Register(() => logger.LogDebug("Git cancellation requested after {Elapsed}.", stopwatch.Elapsed));
        var result = await ProcessCaptureRunner.RunAsync<GitOutput?>(
            startInfo, Timeout.InfiniteTimeSpan,
            async (process, token) =>
            {
                logger.LogDebug("Git process {ProcessId} started after {Elapsed}.", process.Id, stopwatch.Elapsed);
                if (startInfo.RedirectStandardInput)
                {
                    // Captured template commands must see EOF, not consume the CLI's input
                    // or wait for input until the acquisition deadline expires.
                    process.StandardInput.Close();
                }
                if (!startInfo.RedirectStandardOutput)
                {
                    return new GitOutput(string.Empty, string.Empty);
                }
                var stdout = ReadOutputAsync(process.StandardOutput, token);
                var stderr = startInfo.RedirectStandardError
                    ? ReadOutputAsync(process.StandardError, token)
                    : Task.FromResult(string.Empty);
                await Task.WhenAll(stdout, stderr);
                logger.LogDebug("Git process {ProcessId} output drained after {Elapsed}.", process.Id, stopwatch.Elapsed);
                return new GitOutput(await stdout, await stderr);
            },
            () => null, NullLogger.Instance, cancellationToken);
        logger.LogDebug("Git process completed after {Elapsed}; exit code {ExitCode}, failure {Failure}, cancelled {Cancelled}.",
            stopwatch.Elapsed, result.ExitCode, result.FailureKind, result.Cancelled);
        if (cancellationToken.IsCancellationRequested && captureCancellationOutput && result.Capture is { } interrupted)
        {
            throw new GitProcessCanceledException(interrupted.StandardOutput + interrupted.StandardError, cancellationToken);
        }
        cancellationToken.ThrowIfCancellationRequested();
        if (result.FailureKind is ProcessCaptureFailureKind.StartFailed)
        {
            throw new GitCommandException(GitFailure.Unavailable, null);
        }
        if (result.Capture is not { } capture || result.FailureKind is not null)
        {
            throw new GitCommandException(GitFailure.RepositoryUnavailable, null);
        }
        if (result.ExitCode != 0)
        {
            throw CreateCommandException(capture.StandardError, result.ExitCode, mode);
        }
 
        return capture.StandardOutput;
    }
 
    internal static GitCommandException CreateCommandException(string stderr, int exitCode, GitCommandMode mode)
    {
        var authentication = stderr.Contains("authentication", StringComparison.OrdinalIgnoreCase)
            || stderr.Contains("permission denied", StringComparison.OrdinalIgnoreCase)
            || stderr.Contains("could not read Username", StringComparison.OrdinalIgnoreCase)
            || (mode == GitCommandMode.Captured && stderr.Contains("could not read Password", StringComparison.OrdinalIgnoreCase));
        var unavailable = stderr.Contains("repository not found", StringComparison.OrdinalIgnoreCase)
            || stderr.Contains("could not resolve host", StringComparison.OrdinalIgnoreCase)
            || stderr.Contains("unable to access", StringComparison.OrdinalIgnoreCase)
            || stderr.Contains("could not read from remote repository", StringComparison.OrdinalIgnoreCase);
        return new GitCommandException(authentication ? GitFailure.AuthenticationFailed
            : unavailable ? GitFailure.RepositoryUnavailable : GitFailure.CommandFailed, exitCode);
    }
 
    private static async Task<string> ReadOutputAsync(StreamReader reader, CancellationToken cancellationToken)
    {
        var output = new StringBuilder();
        var buffer = new char[4096];
        try
        {
            // Prompts such as "Enter passphrase for key '/home/user/.ssh/id_ed25519': "
            // can lack a newline. Read chunks without normalizing newlines or NUL delimiters.
            while (true)
            {
                var count = await reader.ReadAsync(buffer.AsMemory(), cancellationToken);
                if (count == 0)
                {
                    break;
                }
                output.Append(buffer, 0, count);
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // The capture runner bounds tree shutdown and draining. Retain partial diagnostics.
        }
 
        return output.ToString();
    }
 
    private sealed record GitOutput(string StandardOutput, string StandardError);
}
 
internal sealed class GitProcessCanceledException(string output, CancellationToken cancellationToken) : OperationCanceledException(cancellationToken)
{
    // For explicit user display on timeout only, never exception messages, logs or telemetry.
    public string Output { get; } = output;
}