| File: Git\GitRepository.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 Aspire.Cli.Telemetry; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Git; /// <summary> /// Provides Git repository operations. /// </summary> /// <param name="executionContext">The CLI execution context providing the working directory.</param> /// <param name="environment">The environment abstraction for OS detection.</param> /// <param name="logger">The logger for diagnostic output.</param> /// <param name="profilingTelemetry">The profiling telemetry service.</param> internal sealed class GitRepository(CliExecutionContext executionContext, IEnvironment environment, ILogger<GitRepository> logger, ProfilingTelemetry profilingTelemetry) : IGitRepository { /// <inheritdoc /> public async Task<DirectoryInfo?> GetRootAsync(CancellationToken cancellationToken) { logger.LogDebug("Searching for Git repository root from working directory: {WorkingDirectory}", executionContext.WorkingDirectory.FullName); try { var startInfo = new ProcessStartInfo("git") { WorkingDirectory = executionContext.WorkingDirectory.FullName, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; startInfo.ArgumentList.Add("rev-parse"); startInfo.ArgumentList.Add("--show-toplevel"); using var process = new Process { StartInfo = startInfo }; using var activity = profilingTelemetry.StartGitCommand("rev-parse", startInfo.FileName, startInfo.ArgumentList, executionContext.WorkingDirectory); process.Start(); activity.SetProcessId(process.Id); using var cancellationRegistration = RegisterProcessKillOnCancellation(process, cancellationToken); // Read both streams concurrently so a verbose git failure cannot block on a full stderr // pipe while the CLI is waiting for stdout or process exit. var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); activity.SetProcessExitCode(process.ExitCode); var output = await outputTask.ConfigureAwait(false); var errorOutput = await errorTask.ConfigureAwait(false); activity.SetGitOutputLengths(output.Length, errorOutput.Length); if (process.ExitCode != 0) { activity.SetError($"git rev-parse exited with code {process.ExitCode}."); logger.LogDebug("Git command returned non-zero exit code {ExitCode}: {Error}", process.ExitCode, errorOutput.Trim()); return null; } var rootPath = output.Trim(); if (string.IsNullOrEmpty(rootPath)) { logger.LogDebug("Git command returned empty output"); return null; } var directoryInfo = new DirectoryInfo(rootPath); if (directoryInfo.Exists) { logger.LogDebug("Found Git repository root: {GitRoot}", directoryInfo.FullName); return directoryInfo; } logger.LogDebug("Git repository root path does not exist: {GitRoot}", rootPath); return null; } catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) { // Missing git is not fatal for callers. Ambient discovery treats null as // "git acceleration unavailable" and falls back to the filesystem walker. logger.LogDebug(ex, "Git is not installed or not found in PATH"); return null; } } /// <inheritdoc /> public async Task<IReadOnlySet<string>?> GetIncludedFilesAsync(DirectoryInfo searchRoot, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(searchRoot); if (!searchRoot.Exists) { logger.LogDebug("Search root does not exist: {SearchRoot}", searchRoot.FullName); return null; } logger.LogDebug("Listing git-included files under: {SearchRoot}", searchRoot.FullName); try { var startInfo = new ProcessStartInfo("git") { WorkingDirectory = searchRoot.FullName, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; // -z separates entries with NUL so paths containing newlines or spaces are unambiguous. // --cached: tracked files. --others: untracked files. --exclude-standard: respect .gitignore, // .git/info/exclude, and the user's global excludesfile. Submodule contents are not enumerated. startInfo.ArgumentList.Add("ls-files"); startInfo.ArgumentList.Add("--cached"); startInfo.ArgumentList.Add("--others"); startInfo.ArgumentList.Add("--exclude-standard"); startInfo.ArgumentList.Add("-z"); using var process = new Process { StartInfo = startInfo }; using var activity = profilingTelemetry.StartGitCommand("ls-files", startInfo.FileName, startInfo.ArgumentList, searchRoot); process.Start(); activity.SetProcessId(process.Id); using var cancellationRegistration = RegisterProcessKillOnCancellation(process, cancellationToken); // Read both streams concurrently so a verbose git failure cannot block on a full stderr // pipe while the CLI is waiting for stdout or process exit. var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); activity.SetProcessExitCode(process.ExitCode); var output = await outputTask.ConfigureAwait(false); var errorOutput = await errorTask.ConfigureAwait(false); activity.SetGitOutputLengths(output.Length, errorOutput.Length); if (process.ExitCode != 0) { activity.SetError($"git ls-files exited with code {process.ExitCode}."); logger.LogDebug("git ls-files returned non-zero exit code {ExitCode} from {SearchRoot}: {Error}", process.ExitCode, searchRoot.FullName, errorOutput.Trim()); return null; } var pathComparer = environment.IsWindows() || environment.IsMacOS() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; var includedFiles = new HashSet<string>(pathComparer); var rootFullName = searchRoot.FullName; // `git ls-files -z` emits NUL-delimited paths relative to searchRoot, for example: // `src/AppHost/AppHost.csproj\0playground/apphost.ts\0`. Git always uses '/' as the // separator in this output, and the trailing NUL produces an empty split entry. foreach (var rawPath in output.Split('\0', StringSplitOptions.RemoveEmptyEntries)) { var relativePath = Path.DirectorySeparatorChar == '/' ? rawPath : rawPath.Replace('/', Path.DirectorySeparatorChar); var absolutePath = Path.GetFullPath(Path.Combine(rootFullName, relativePath)); includedFiles.Add(absolutePath); } logger.LogDebug("git ls-files returned {Count} files under {SearchRoot}", includedFiles.Count, searchRoot.FullName); return includedFiles; } catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) { // Missing git is not fatal for callers. Ambient discovery treats null as // "git acceleration unavailable" and falls back to the filesystem walker. logger.LogDebug(ex, "Git is not installed or not found in PATH"); return null; } } private static CancellationTokenRegistration RegisterProcessKillOnCancellation(Process process, CancellationToken cancellationToken) { // Process.WaitForExitAsync(cancellationToken) cancels the wait but does not terminate the // child process. These are short-lived git commands owned by the CLI, so Ctrl+C should stop // the git process tree instead of leaving `git ls-files` walking a large repo after exit. if (!cancellationToken.CanBeCanceled) { return default; } return cancellationToken.Register(static state => { var process = (Process)state!; try { if (!process.HasExited) { process.Kill(entireProcessTree: true); } } catch (InvalidOperationException) { // The process can exit between HasExited and Kill. Cancellation already won, so // cleanup is best effort. } catch (System.ComponentModel.Win32Exception) { // Process termination can race with OS teardown or permission checks. Treat that // the same as an already-exited process rather than surfacing a secondary error. } }, process); } }