Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Git/GitRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ private static ProcessStartInfo CreateGitStartInfo(DirectoryInfo workingDirector

private async Task<ProcessTextOutput> RunGitAsync(ProcessStartInfo startInfo, ProfilingTelemetry.ActivityScope activity, CancellationToken cancellationToken)
{
// Git can block on Windows if it inherits the MCP stdin pipe while the server is reading it.
// https://github.com/microsoft/aspire/issues/21877
using var standardInput = File.OpenNullHandle();
startInfo.StandardInputHandle = standardInput;

// RunAndCaptureTextAsync drains stdout and stderr concurrently, and kills git when
// cancellationToken fires so Ctrl+C doesn't leave `git ls-files` walking a large repo.
var result = await Process.RunAndCaptureTextAsync(startInfo, cancellationToken).ConfigureAwait(false);
Expand Down
65 changes: 65 additions & 0 deletions tests/Aspire.Cli.Tests/Git/GitRepositoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Aspire.Cli.Git;
using Aspire.Cli.Mcp;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.Utils;
using Aspire.Tests;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.DotNet.RemoteExecutor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

namespace Aspire.Cli.Tests.Git;

Expand Down Expand Up @@ -51,6 +57,65 @@ public async Task GetIncludedFilesAsync_LogsGitOutput(bool initialized)
}
}

[Fact]
[PlatformSpecific(TestPlatforms.Windows)]
public async Task GetRootAsync_WhileMcpReadsStandardInput_Completes()
{
await GitTestHelper.EnsureGitAvailableAsync(outputHelper);
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await workspace.InitializeGitAsync();
var options = new RemoteInvokeOptions
{
StartInfo = { RedirectStandardInput = true, RedirectStandardOutput = true }
};
options.StartInfo.Environment.Remove("GIT_REDIRECT_STDIN");

// A real stdio server reproduces the inherited pipe read without changing the test host's stdin.
using var remote = RemoteExecutor.Invoke(static async directoryPath =>
{
var directory = new DirectoryInfo(directoryPath);
var context = new CliExecutionContext(directory, directory, directory, directory, directory, "unused.log", "local");
using var profilingTelemetry = CreateProfilingTelemetry();
var repository = new GitRepository(context, new TestEnvironment(), NullLogger<GitRepository>.Instance, profilingTelemetry);
var serverOptions = new McpServerOptions
{
ServerInfo = new() { Name = "git-test", Version = "1.0" },
Handlers = new()
{
CallToolHandler = async (_, cancellationToken) =>
{
var root = await repository.GetRootAsync(cancellationToken);
return new CallToolResult
{
Content = [new TextContentBlock { Text = root?.FullName ?? string.Empty }]
};
}
}
};
var transport = new StdioMcpTransportFactory(NullLoggerFactory.Instance).CreateTransport();
await using var server = McpServer.Create(transport, serverOptions);
await server.RunAsync();
}, workspace.WorkspaceRoot.FullName, options);

try
{
var clientTransport = new StreamClientTransport(
serverInput: remote.Process.StandardInput.BaseStream,
serverOutput: remote.Process.StandardOutput.BaseStream);
await using var client = await McpClient.CreateAsync(clientTransport).DefaultTimeout();
using var cts = new CancellationTokenSource(TestConstants.DefaultTimeoutDuration);
var result = await client.CallToolAsync("git_root", cancellationToken: cts.Token);

Assert.False(result.IsError ?? false);
Assert.Collection(result.Content,
block => Assert.Equal(workspace.WorkspaceRoot.FullName, Assert.IsType<TextContentBlock>(block).Text));
}
finally
{
remote.Process.StandardInput.Close();
}
}

[Fact]
public async Task GetIncludedFilesAsync_OutsideRepo_ReturnsNull()
{
Expand Down
Loading