Skip to content

[watch] Fix /bl option parsing, use it for build logging #50002

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 5, 2025
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
4 changes: 2 additions & 2 deletions src/BuiltInTools/dotnet-watch/Build/BuildReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@

namespace Microsoft.DotNet.Watch;

internal sealed class BuildReporter(IReporter reporter, EnvironmentOptions environmentOptions)
internal sealed class BuildReporter(IReporter reporter, GlobalOptions options, EnvironmentOptions environmentOptions)
{
public IReporter Reporter => reporter;
public EnvironmentOptions EnvironmentOptions => environmentOptions;

public Loggers GetLoggers(string projectPath, string operationName)
=> new(reporter, environmentOptions.GetTestBinLogPath(projectPath, operationName));
=> new(reporter, environmentOptions.GetBinLogPath(projectPath, operationName, options));

public void ReportWatchedFiles(Dictionary<string, FileItem> fileItems)
{
Expand Down
3 changes: 2 additions & 1 deletion src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ public void WatchFiles(FileWatcher fileWatcher)
string rootProjectPath,
IEnumerable<string> buildArguments,
IReporter reporter,
GlobalOptions options,
EnvironmentOptions environmentOptions,
bool restore,
CancellationToken cancellationToken)
{
var buildReporter = new BuildReporter(reporter, environmentOptions);
var buildReporter = new BuildReporter(reporter, options, environmentOptions);

// See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md

Expand Down
104 changes: 80 additions & 24 deletions src/BuiltInTools/dotnet-watch/CommandLine/CommandLineOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Data;
using System.Diagnostics;
using Microsoft.Build.Logging;
using Microsoft.DotNet.Cli;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Extensions;
Expand All @@ -15,19 +17,28 @@ internal sealed class CommandLineOptions
{
public const string DefaultCommand = "run";

private static readonly ImmutableArray<string> s_binaryLogOptionNames = ["-bl", "/bl", "-binaryLogger", "--binaryLogger", "/binaryLogger"];

public bool List { get; init; }
required public GlobalOptions GlobalOptions { get; init; }
public required GlobalOptions GlobalOptions { get; init; }

public string? ProjectPath { get; init; }
public string? TargetFramework { get; init; }
public bool NoLaunchProfile { get; init; }
public string? LaunchProfileName { get; init; }

public string? ExplicitCommand { get; init; }

/// <summary>
/// Arguments passed to <see cref="Command"/>.
/// </summary>
public required IReadOnlyList<string> CommandArguments { get; init; }

/// <summary>
/// Arguments passed to `dotnet build` and to design-time build evaluation.
/// </summary>
public required IReadOnlyList<string> BuildArguments { get; init; }

public string? ExplicitCommand { get; init; }

public string Command => ExplicitCommand ?? DefaultCommand;

// this option is referenced from inner logic and so needs to be reference-able
Expand Down Expand Up @@ -128,6 +139,7 @@ internal sealed class CommandLineOptions
Output = output,
Error = output
});

if (!rootCommandInvoked)
{
// help displayed:
Expand All @@ -145,10 +157,16 @@ internal sealed class CommandLineOptions
}
}

var commandArguments = GetCommandArguments(parseResult, watchOptions, explicitCommand);
var commandArguments = GetCommandArguments(parseResult, watchOptions, explicitCommand, out var binLogToken, out var binLogPath);

// We assume that forwarded options, if any, are intended for dotnet build.
var buildArguments = buildOptions.Select(option => ((IForwardedOption)option).GetForwardingFunction()(parseResult)).SelectMany(args => args).ToArray();
var buildArguments = buildOptions.Select(option => ((IForwardedOption)option).GetForwardingFunction()(parseResult)).SelectMany(args => args).ToList();

if (binLogToken != null)
{
buildArguments.Add(binLogToken);
}

var targetFrameworkOption = (Option<string>?)buildOptions.SingleOrDefault(option => option.Name == "--framework");

return new()
Expand All @@ -160,6 +178,7 @@ internal sealed class CommandLineOptions
NoHotReload = parseResult.GetValue(noHotReloadOption),
NonInteractive = parseResult.GetValue(NonInteractiveOption),
Verbose = parseResult.GetValue(verboseOption),
BinaryLogPath = ParseBinaryLogFilePath(binLogPath),
},

CommandArguments = commandArguments,
Expand All @@ -173,12 +192,36 @@ internal sealed class CommandLineOptions
};
}

/// <summary>
/// Parses the value of msbuild option `-binaryLogger[:[LogFile=]output.binlog[;ProjectImports={None,Embed,ZipFile}]]`.
/// Emulates https://github.com/dotnet/msbuild/blob/7f69ea906c29f2478cc05423484ad185de66e124/src/Build/Logging/BinaryLogger/BinaryLogger.cs#L481.
/// See https://github.com/dotnet/msbuild/issues/12256
/// </summary>
internal static string? ParseBinaryLogFilePath(string? value)
=> value switch
{
null => null,
_ => (from parameter in value.Split(';', StringSplitOptions.RemoveEmptyEntries)
where !string.Equals(parameter, "ProjectImports=None", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(parameter, "ProjectImports=Embed", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(parameter, "ProjectImports=ZipFile", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(parameter, "OmitInitialInfo", StringComparison.OrdinalIgnoreCase)
let path = (parameter.StartsWith("LogFile=", StringComparison.OrdinalIgnoreCase) ? parameter["LogFile=".Length..] : parameter).Trim('"')
let pathWithExtension = path.EndsWith(".binlog", StringComparison.OrdinalIgnoreCase) ? path : $"{path}.binlog"
select pathWithExtension)
.LastOrDefault("msbuild.binlog")
};

private static IReadOnlyList<string> GetCommandArguments(
ParseResult parseResult,
IReadOnlyList<Option> watchOptions,
Command? explicitCommand)
Command? explicitCommand,
out string? binLogToken,
out string? binLogPath)
{
var arguments = new List<string>();
binLogToken = null;
binLogPath = null;

foreach (var child in parseResult.CommandResult.Children)
{
Expand All @@ -199,23 +242,11 @@ private static IReadOnlyList<string> GetCommandArguments(
continue;
}

// Some options _may_ be computed or have defaults, so not all may have an IdentifierToken.
// For those that do not, use the Option's Name instead.
var optionNameToForward = optionResult.IdentifierToken?.Value ?? optionResult.Option.Name;
var optionNameToForward = GetOptionNameToForward(optionResult);
if (optionResult.Tokens.Count == 0 && !optionResult.Implicit)
{
arguments.Add(optionNameToForward);
}
else if (optionResult.Option.Name == "--property")
{
foreach (var token in optionResult.Tokens)
{
// While dotnet-build allows "/p Name=Value", dotnet-msbuild does not.
// Any command that forwards args to dotnet-msbuild will fail if we don't use colon.
// See https://github.com/dotnet/sdk/issues/44655.
arguments.Add($"{optionNameToForward}:{token.Value}");
}
}
else
{
foreach (var token in optionResult.Tokens)
Expand All @@ -227,8 +258,6 @@ private static IReadOnlyList<string> GetCommandArguments(
}
}

var tokens = parseResult.UnmatchedTokens.ToArray();

// Assuming that all tokens after "--" are unmatched:
var dashDashIndex = IndexOf(parseResult.Tokens, t => t.Value == "--");
var unmatchedTokensBeforeDashDash = parseResult.UnmatchedTokens.Count - (dashDashIndex >= 0 ? parseResult.Tokens.Count - dashDashIndex - 1 : 0);
Expand All @@ -240,10 +269,32 @@ private static IReadOnlyList<string> GetCommandArguments(
{
var token = parseResult.UnmatchedTokens[i];

if (i < unmatchedTokensBeforeDashDash && !seenCommand && token == explicitCommand?.Name)
if (i < unmatchedTokensBeforeDashDash)
{
seenCommand = true;
continue;
if (!seenCommand && token == explicitCommand?.Name)
{
seenCommand = true;
continue;
}

// Workaround: commands do not have forwarding option for -bl
// https://github.com/dotnet/sdk/issues/49989
foreach (var name in s_binaryLogOptionNames)
{
if (token.StartsWith(name, StringComparison.OrdinalIgnoreCase))
{
if (token.Length == name.Length)
{
binLogToken = token;
binLogPath = "";
}
else if (token.Length > name.Length + 1 && token[name.Length] == ':')
{
binLogToken = token;
binLogPath = token[(name.Length + 1)..];
}
}
}
}

if (!dashDashInserted && i >= unmatchedTokensBeforeDashDash)
Expand All @@ -258,6 +309,11 @@ private static IReadOnlyList<string> GetCommandArguments(
return arguments;
}

private static string GetOptionNameToForward(OptionResult optionResult)
// Some options _may_ be computed or have defaults, so not all may have an IdentifierToken.
// For those that do not, use the Option's Name instead.
=> optionResult.IdentifierToken?.Value ?? optionResult.Option.Name;

private static Command? TryGetSubcommand(ParseResult parseResult)
{
// Assuming that all tokens after "--" are unmatched:
Expand Down
10 changes: 5 additions & 5 deletions src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ internal sealed record EnvironmentOptions(
TestOutput: EnvironmentVariables.TestOutputDir
);

private static int s_uniqueLogId;
private int _uniqueLogId;

public bool RunningAsTest { get => (TestFlags & TestFlags.RunningAsTest) != TestFlags.None; }

Expand All @@ -64,9 +64,9 @@ private static string GetMuxerPathFromEnvironment()
return muxerPath;
}

public string? GetTestBinLogPath(string projectPath, string operationName)
=> TestFlags.HasFlag(TestFlags.RunningAsTest)
? Path.Combine(TestOutput, $"Watch.{operationName}.{Path.GetFileName(projectPath)}.{Interlocked.Increment(ref s_uniqueLogId)}.binlog")
: null;
public string? GetBinLogPath(string projectPath, string operationName, GlobalOptions options)
=> options.BinaryLogPath != null
? $"{Path.Combine(WorkingDirectory, options.BinaryLogPath)[..^".binlog".Length]}-dotnet-watch.{operationName}.{Path.GetFileName(projectPath)}.{Interlocked.Increment(ref _uniqueLogId)}.binlog"
: null;
}
}
6 changes: 6 additions & 0 deletions src/BuiltInTools/dotnet-watch/CommandLine/GlobalOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,10 @@ internal sealed class GlobalOptions
public bool Verbose { get; init; }
public bool NoHotReload { get; init; }
public bool NonInteractive { get; init; }

/// <summary>
/// Path to binlog file (absolute or relative to working directory, includes .binlog extension),
/// or null to not generate binlog files.
/// </summary>
public string? BinaryLogPath { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken)

var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, _context.Reporter);
compilationHandler = new CompilationHandler(_context.Reporter, _context.ProcessRunner);
var scopedCssFileHandler = new ScopedCssFileHandler(_context.Reporter, projectMap, browserConnector, _context.EnvironmentOptions);
var scopedCssFileHandler = new ScopedCssFileHandler(_context.Reporter, projectMap, browserConnector, _context.Options, _context.EnvironmentOptions);
var projectLauncher = new ProjectLauncher(_context, projectMap, browserConnector, compilationHandler, iteration);
evaluationResult.ItemExclusions.Report(_context.Reporter);

Expand Down Expand Up @@ -620,6 +620,11 @@ private bool AcceptChange(ChangedPath change)
{
var (path, kind) = change;

if (Path.GetExtension(path) == ".binlog")
{
return false;
}

if (PathUtilities.GetContainingDirectories(path).FirstOrDefault(IsHiddenDirectory) is { } containingHiddenDir)
{
_context.Reporter.Report(MessageDescriptor.IgnoringChangeInHiddenDirectory, containingHiddenDir, kind, path);
Expand Down Expand Up @@ -765,6 +770,7 @@ private async ValueTask<EvaluationResult> EvaluateRootProjectAsync(bool restore,
_context.RootProjectOptions.ProjectPath,
_context.RootProjectOptions.BuildArguments,
_context.Reporter,
_context.Options,
_context.EnvironmentOptions,
restore,
cancellationToken);
Expand All @@ -788,10 +794,6 @@ await FileWatcher.WaitForFileChangeAsync(
{
var buildOutput = new List<OutputLine>();

string[] binLogArguments = _context.EnvironmentOptions.GetTestBinLogPath(projectPath, "Build") is { } binLogPath
? [$"-bl:{binLogPath}"]
: [];

var processSpec = new ProcessSpec
{
Executable = _context.EnvironmentOptions.MuxerPath,
Expand All @@ -805,7 +807,7 @@ await FileWatcher.WaitForFileChangeAsync(
}
},
// pass user-specified build arguments last to override defaults:
Arguments = ["build", projectPath, "-consoleLoggerParameters:NoSummary;Verbosity=minimal", .. binLogArguments, .. buildArguments],
Arguments = ["build", projectPath, "-consoleLoggerParameters:NoSummary;Verbosity=minimal", .. buildArguments]
};

_context.Reporter.Output($"Building {projectPath} ...");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace Microsoft.DotNet.Watch
{
internal sealed class ScopedCssFileHandler(IReporter reporter, ProjectNodeMap projectMap, BrowserConnector browserConnector, EnvironmentOptions environmentOptions)
internal sealed class ScopedCssFileHandler(IReporter reporter, ProjectNodeMap projectMap, BrowserConnector browserConnector, GlobalOptions options, EnvironmentOptions environmentOptions)
{
private const string BuildTargetName = TargetNames.GenerateComputedBuildStaticWebAssets;

Expand Down Expand Up @@ -53,7 +53,7 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList<ChangedFile> files,
return;
}

var buildReporter = new BuildReporter(reporter, environmentOptions);
var buildReporter = new BuildReporter(reporter, options, environmentOptions);

var buildTasks = projectsToRefresh.Select(projectNode => Task.Run(() =>
{
Expand Down
2 changes: 1 addition & 1 deletion src/BuiltInTools/dotnet-watch/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ private async Task<int> ListFilesAsync(ProcessRunner processRunner, Cancellation
rootProjectOptions.ProjectPath,
rootProjectOptions.BuildArguments,
processRunner,
new BuildReporter(reporter, environmentOptions));
new BuildReporter(reporter, options.GlobalOptions, environmentOptions));

if (await fileSetFactory.TryCreateAsync(requireProjectGraph: null, cancellationToken) is not { } evaluationResult)
{
Expand Down
6 changes: 2 additions & 4 deletions src/BuiltInTools/dotnet-watch/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@
"profiles": {
"dotnet-watch": {
"commandName": "Project",
"commandLineArgs": "--verbose /bl:DotnetRun.binlog",
"commandLineArgs": "--verbose -bl",
"workingDirectory": "E:\\sdk\\artifacts\\tmp\\Debug\\Aspire_BuildE---04F22750\\WatchAspire.AppHost",
"environmentVariables": {
"DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)",
"DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000",
"DCP_IDE_NOTIFICATION_TIMEOUT_SECONDS": "100000",
"DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS": "100000",
"DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS": "100000",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "1",
"__DOTNET_WATCH_TEST_FLAGS": "",
"__DOTNET_WATCH_TEST_OUTPUT_DIR": "$(RepoRoot)\\artifacts\\tmp\\Debug"
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "1"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ protected virtual MSBuildFileSetFactory CreateMSBuildFileSetFactory()
_context.RootProjectOptions.ProjectPath,
_context.RootProjectOptions.BuildArguments,
_context.ProcessRunner,
new BuildReporter(_context.Reporter, _context.EnvironmentOptions));
new BuildReporter(_context.Reporter, _context.Options, _context.EnvironmentOptions));

public IReadOnlyList<string> GetProcessArguments(int iteration)
{
Expand Down
5 changes: 0 additions & 5 deletions src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,6 @@ private IReadOnlyList<string> GetMSBuildArguments(string watchListFilePath)
"/t:" + TargetName
};

if (EnvironmentOptions.GetTestBinLogPath(rootProjectFile, "GenerateWatchList") is { } binLogPath)
{
arguments.Add($"/bl:{binLogPath}");
}

arguments.AddRange(buildArguments);

// Set dotnet-watch reserved properties after the user specified propeties,
Expand Down
Loading
Loading