From fb5a88f908d3c186d74a06c1a5e98851f6328c22 Mon Sep 17 00:00:00 2001
From: Jan Jones <janjones@microsoft.com>
Date: Fri, 20 Jun 2025 14:27:14 +0200
Subject: [PATCH 1/2] Support `dotnet clean file.cs`

---
 documentation/general/dotnet-run-file.md      |  7 ++--
 src/Cli/dotnet/Commands/Clean/CleanCommand.cs | 36 ++++++++++++++-----
 .../Commands/Clean/CleanCommandParser.cs      |  6 ++--
 test/dotnet.Tests/CliSchemaTests.cs           |  4 +--
 .../MSBuild/GivenDotnetCleanInvocation.cs     |  4 +--
 .../CommandTests/Run/RunFileTests.cs          | 26 ++++++++++++++
 ...apshotTests.VerifyCompletions.verified.zsh |  2 +-
 7 files changed, 66 insertions(+), 19 deletions(-)

diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md
index a4baf3c4e21b..4a3e58c4901e 100644
--- a/documentation/general/dotnet-run-file.md
+++ b/documentation/general/dotnet-run-file.md
@@ -79,6 +79,8 @@ Command `dotnet publish file.cs` is also supported for file-based programs.
 Note that file-based apps have implicitly set `PublishAot=true`, so publishing uses Native AOT (and building reports AOT warnings).
 To opt out, use `#:property PublishAot=false` directive in your `.cs` file.
 
+Command `dotnet clean file.cs` can be used to clean build artifacts of the file-based program.
+
 ## Entry points
 
 If a file is given to `dotnet run`, it has to be an *entry-point file*, otherwise an error is reported.
@@ -331,9 +333,8 @@ or as the first argument if it makes sense for them.
 We could also add `dotnet compile` command that would be the equivalent of `dotnet build` but for file-based programs
 (because "compiling" might make more sense for file-based programs than "building").
 
-`dotnet clean` could be extended to support cleaning [the output directory](#build-outputs),
-e.g., via `dotnet clean --file-based-program <path-to-entry-point>`
-or `dotnet clean --all-file-based-programs`.
+`dotnet clean` could be extended to support cleaning all file-based app outputs,
+e.g., `dotnet clean --all-file-based-apps`.
 
 Adding references via `dotnet package add`/`dotnet reference add` could be supported for file-based programs as well,
 i.e., the command would add a `#:package`/`#:project` directive to the top of a `.cs` file.
diff --git a/src/Cli/dotnet/Commands/Clean/CleanCommand.cs b/src/Cli/dotnet/Commands/Clean/CleanCommand.cs
index 5804b7338764..294c64dda901 100644
--- a/src/Cli/dotnet/Commands/Clean/CleanCommand.cs
+++ b/src/Cli/dotnet/Commands/Clean/CleanCommand.cs
@@ -1,25 +1,23 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-#nullable disable
-
 using System.CommandLine;
 using Microsoft.DotNet.Cli.Commands.MSBuild;
+using Microsoft.DotNet.Cli.Commands.Run;
 using Microsoft.DotNet.Cli.Extensions;
 
 namespace Microsoft.DotNet.Cli.Commands.Clean;
 
-public class CleanCommand(IEnumerable<string> msbuildArgs, string msbuildPath = null) : MSBuildForwardingApp(msbuildArgs, msbuildPath)
+public class CleanCommand(IEnumerable<string> msbuildArgs, string? msbuildPath = null) : MSBuildForwardingApp(msbuildArgs, msbuildPath)
 {
-    public static CleanCommand FromArgs(string[] args, string msbuildPath = null)
+    public static CommandBase FromArgs(string[] args, string? msbuildPath = null)
     {
-
         var parser = Parser.Instance;
         var result = parser.ParseFrom("dotnet clean", args);
         return FromParseResult(result, msbuildPath);
     }
 
-    public static CleanCommand FromParseResult(ParseResult result, string msbuildPath = null)
+    public static CommandBase FromParseResult(ParseResult result, string? msbuildPath = null)
     {
         var msbuildArgs = new List<string>
         {
@@ -28,11 +26,33 @@ public static CleanCommand FromParseResult(ParseResult result, string msbuildPat
 
         result.ShowHelpOrErrorIfAppropriate();
 
-        msbuildArgs.AddRange(result.GetValue(CleanCommandParser.SlnOrProjectArgument) ?? []);
+        var args = result.GetValue(CleanCommandParser.SlnOrProjectOrFileArgument) ?? [];
+
+        LoggerUtility.SeparateBinLogArguments(args, out var binLogArgs, out var nonBinLogArgs);
+
+        var forwardedArgs = result.OptionValuesToBeForwarded(CleanCommandParser.GetCommand());
+
+        if (nonBinLogArgs is [{ } arg] && VirtualProjectBuildingCommand.IsValidEntryPointPath(arg))
+        {
+            msbuildArgs.AddRange(binLogArgs);
+            msbuildArgs.AddRange(forwardedArgs);
+
+            return new VirtualProjectBuildingCommand(
+                entryPointFileFullPath: Path.GetFullPath(arg),
+                msbuildArgs: [.. msbuildArgs])
+            {
+                NoBuild = false,
+                NoRestore = true,
+                NoCache = true,
+                BuildTarget = "Clean",
+            };
+        }
+
+        msbuildArgs.AddRange(args);
 
         msbuildArgs.Add("-target:Clean");
 
-        msbuildArgs.AddRange(result.OptionValuesToBeForwarded(CleanCommandParser.GetCommand()));
+        msbuildArgs.AddRange(forwardedArgs);
 
         return new CleanCommand(msbuildArgs, msbuildPath);
     }
diff --git a/src/Cli/dotnet/Commands/Clean/CleanCommandParser.cs b/src/Cli/dotnet/Commands/Clean/CleanCommandParser.cs
index fc73c81e8efd..e37969f03a4a 100644
--- a/src/Cli/dotnet/Commands/Clean/CleanCommandParser.cs
+++ b/src/Cli/dotnet/Commands/Clean/CleanCommandParser.cs
@@ -12,9 +12,9 @@ internal static class CleanCommandParser
 {
     public static readonly string DocsLink = "https://aka.ms/dotnet-clean";
 
-    public static readonly Argument<IEnumerable<string>> SlnOrProjectArgument = new(CliStrings.SolutionOrProjectArgumentName)
+    public static readonly Argument<IEnumerable<string>> SlnOrProjectOrFileArgument = new(CliStrings.SolutionOrProjectOrFileArgumentName)
     {
-        Description = CliStrings.SolutionOrProjectArgumentDescription,
+        Description = CliStrings.SolutionOrProjectOrFileArgumentDescription,
         Arity = ArgumentArity.ZeroOrMore
     };
 
@@ -45,7 +45,7 @@ private static Command ConstructCommand()
     {
         DocumentedCommand command = new("clean", DocsLink, CliCommandStrings.CleanAppFullName);
 
-        command.Arguments.Add(SlnOrProjectArgument);
+        command.Arguments.Add(SlnOrProjectOrFileArgument);
         command.Options.Add(FrameworkOption);
         command.Options.Add(CommonOptions.RuntimeOption(CliCommandStrings.CleanRuntimeOptionDescription));
         command.Options.Add(ConfigurationOption);
diff --git a/test/dotnet.Tests/CliSchemaTests.cs b/test/dotnet.Tests/CliSchemaTests.cs
index d0a50c640b4e..c538fc527c76 100644
--- a/test/dotnet.Tests/CliSchemaTests.cs
+++ b/test/dotnet.Tests/CliSchemaTests.cs
@@ -42,8 +42,8 @@ public CliSchemaTests(ITestOutputHelper log) : base(log)
   "description": ".NET Clean Command",
   "hidden": false,
   "arguments": {
-    "PROJECT | SOLUTION": {
-      "description": "The project or solution file to operate on. If a file is not specified, the command will search the current directory for one.",
+    "PROJECT | SOLUTION | FILE": {
+      "description": "The project or solution or C# (file-based program) file to operate on. If a file is not specified, the command will search the current directory for a project or solution.",
       "order": 0,
       "hidden": false,
       "valueType": "System.Collections.Generic.IEnumerable<System.String>",
diff --git a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetCleanInvocation.cs b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetCleanInvocation.cs
index ce44e25588a2..7ca9ab3fef8b 100644
--- a/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetCleanInvocation.cs
+++ b/test/dotnet.Tests/CommandTests/MSBuild/GivenDotnetCleanInvocation.cs
@@ -19,7 +19,7 @@ public class GivenDotnetCleanInvocation : IClassFixture<NullCurrentSessionIdFixt
         public void ItAddsProjectToMsbuildInvocation()
         {
             var msbuildPath = "<msbuildpath>";
-            CleanCommand.FromArgs(new string[] { "<project>" }, msbuildPath)
+            ((CleanCommand)CleanCommand.FromArgs(new string[] { "<project>" }, msbuildPath))
                 .GetArgumentTokensToMSBuild()
                 .Should()
                 .BeEquivalentTo([.. ExpectedPrefix, "<project>"]);
@@ -56,7 +56,7 @@ public void MsbuildInvocationIsCorrect(string[] args, string[] expectedAdditiona
                     .ToArray();
 
                 var msbuildPath = "<msbuildpath>";
-                CleanCommand.FromArgs(args, msbuildPath)
+                ((CleanCommand)CleanCommand.FromArgs(args, msbuildPath))
                     .GetArgumentTokensToMSBuild()
                     .Should()
                     .BeEquivalentTo([.. ExpectedPrefix, .. expectedAdditionalArgs]);
diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
index b31da0a26864..01f889efdf3e 100644
--- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
+++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
@@ -1017,6 +1017,32 @@ public void Publish_Options()
         new DirectoryInfo(testInstance.Path).File("msbuild.binlog").Should().Exist();
     }
 
+    [Fact]
+    public void Clean()
+    {
+        var testInstance = _testAssetsManager.CreateTestDirectory();
+        var programFile = Path.Join(testInstance.Path, "Program.cs");
+        File.WriteAllText(programFile, s_program);
+
+        new DotnetCommand(Log, "run", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass()
+            .And.HaveStdOut("Hello from Program");
+
+        var artifactsDir = VirtualProjectBuildingCommand.GetArtifactsPath(programFile);
+        var dllFile = new FileInfo(Path.Join(artifactsDir, "bin", "debug", "Program.dll"));
+        dllFile.Should().Exist();
+
+        new DotnetCommand(Log, "clean", "Program.cs")
+            .WithWorkingDirectory(testInstance.Path)
+            .Execute()
+            .Should().Pass();
+
+        dllFile.Refresh();
+        dllFile.Should().NotExist();
+    }
+
     [PlatformSpecificFact(TestPlatforms.AnyUnix), UnsupportedOSPlatform("windows")]
     public void ArtifactsDirectory_Permissions()
     {
diff --git a/test/dotnet.Tests/CompletionTests/snapshots/zsh/DotnetCliSnapshotTests.VerifyCompletions.verified.zsh b/test/dotnet.Tests/CompletionTests/snapshots/zsh/DotnetCliSnapshotTests.VerifyCompletions.verified.zsh
index 368c7bfced60..9e8272ffe3b6 100644
--- a/test/dotnet.Tests/CompletionTests/snapshots/zsh/DotnetCliSnapshotTests.VerifyCompletions.verified.zsh
+++ b/test/dotnet.Tests/CompletionTests/snapshots/zsh/DotnetCliSnapshotTests.VerifyCompletions.verified.zsh
@@ -121,7 +121,7 @@ _testhost() {
                         '--disable-build-servers[Force the command to ignore any persistent build servers.]' \
                         '--help[Show command line help.]' \
                         '-h[Show command line help.]' \
-                        '*::PROJECT | SOLUTION -- The project or solution file to operate on. If a file is not specified, the command will search the current directory for one.: ' \
+                        '*::PROJECT | SOLUTION | FILE -- The project or solution or C# (file-based program) file to operate on. If a file is not specified, the command will search the current directory for a project or solution.: ' \
                         && ret=0
                         case $state in
                             (dotnet_dynamic_complete)

From a36e70fc34c65f8626d37fb85849849b2315b0e7 Mon Sep 17 00:00:00 2001
From: Jan Jones <janjones@microsoft.com>
Date: Fri, 20 Jun 2025 14:48:16 +0200
Subject: [PATCH 2/2] Clean also build markers

---
 src/Cli/dotnet/Commands/Clean/CleanCommand.cs |  1 +
 .../Run/VirtualProjectBuildingCommand.cs      | 23 +++++++++++++++++++
 .../CommandTests/Run/RunFileTests.cs          | 20 ++++++++++++++--
 3 files changed, 42 insertions(+), 2 deletions(-)

diff --git a/src/Cli/dotnet/Commands/Clean/CleanCommand.cs b/src/Cli/dotnet/Commands/Clean/CleanCommand.cs
index 294c64dda901..fb86db062ca8 100644
--- a/src/Cli/dotnet/Commands/Clean/CleanCommand.cs
+++ b/src/Cli/dotnet/Commands/Clean/CleanCommand.cs
@@ -45,6 +45,7 @@ public static CommandBase FromParseResult(ParseResult result, string? msbuildPat
                 NoRestore = true,
                 NoCache = true,
                 BuildTarget = "Clean",
+                NoBuildMarkers = true,
             };
         }
 
diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
index 43c45917c2d4..833661693b18 100644
--- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
+++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
@@ -131,6 +131,12 @@ public VirtualProjectBuildingCommand(
     public bool NoBuild { get; init; }
     public string BuildTarget { get; init; } = "Build";
 
+    /// <summary>
+    /// If <see langword="true"/>, no build markers are written
+    /// (like <see cref="BuildStartCacheFileName"/> and <see cref="BuildSuccessCacheFileName"/>).
+    /// </summary>
+    public bool NoBuildMarkers { get; init; }
+
     public override int Execute()
     {
         Debug.Assert(!(NoRestore && NoBuild));
@@ -402,6 +408,11 @@ Building because previous global properties count ({previousCacheEntry.GlobalPro
 
     private void MarkBuildStart()
     {
+        if (NoBuildMarkers)
+        {
+            return;
+        }
+
         string directory = GetArtifactsPath();
 
         if (OperatingSystem.IsWindows())
@@ -421,6 +432,11 @@ private void MarkBuildStart()
 
     private void MarkBuildSuccess(RunFileBuildCacheEntry cacheEntry)
     {
+        if (NoBuildMarkers)
+        {
+            return;
+        }
+
         string successCacheFile = Path.Join(GetArtifactsPath(), BuildSuccessCacheFileName);
         using var stream = File.Open(successCacheFile, FileMode.Create, FileAccess.Write, FileShare.None);
         JsonSerializer.Serialize(stream, cacheEntry, RunFileJsonSerializerContext.Default.RunFileBuildCacheEntry);
@@ -530,6 +546,9 @@ public static void WriteProjectFile(
         {
             Debug.Assert(!string.IsNullOrWhiteSpace(artifactsPath));
 
+            // Note that ArtifactsPath needs to be specified before Sdk.props
+            // (usually it's recommended to specify it in Directory.Build.props
+            // but importing Sdk.props manually afterwards also works).
             writer.WriteLine($"""
                 <Project>
 
@@ -538,6 +557,10 @@ public static void WriteProjectFile(
                     <ArtifactsPath>{EscapeValue(artifactsPath)}</ArtifactsPath>
                   </PropertyGroup>
 
+                  <ItemGroup>
+                    <Clean Include="{EscapeValue(artifactsPath)}/*" />
+                  </ItemGroup>
+
                   <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
                   <Import Project="Sdk.props" Sdk="{EscapeValue(sdkValue)}" />
                 """);
diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
index 01f889efdf3e..4acbd83b6f9e 100644
--- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
+++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
@@ -1030,8 +1030,10 @@ public void Clean()
             .Should().Pass()
             .And.HaveStdOut("Hello from Program");
 
-        var artifactsDir = VirtualProjectBuildingCommand.GetArtifactsPath(programFile);
-        var dllFile = new FileInfo(Path.Join(artifactsDir, "bin", "debug", "Program.dll"));
+        var artifactsDir = new DirectoryInfo(VirtualProjectBuildingCommand.GetArtifactsPath(programFile));
+        artifactsDir.Should().HaveFiles(["build-start.cache", "build-success.cache"]);
+
+        var dllFile = artifactsDir.File("bin/debug/Program.dll");
         dllFile.Should().Exist();
 
         new DotnetCommand(Log, "clean", "Program.cs")
@@ -1039,6 +1041,8 @@ public void Clean()
             .Execute()
             .Should().Pass();
 
+        artifactsDir.EnumerateFiles().Should().BeEmpty();
+
         dllFile.Refresh();
         dllFile.Should().NotExist();
     }
@@ -1517,6 +1521,10 @@ public void Api()
                         <ArtifactsPath>/artifacts</ArtifactsPath>
                       </PropertyGroup>
 
+                      <ItemGroup>
+                        <Clean Include="/artifacts/*" />
+                      </ItemGroup>
+
                       <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
                       <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
                       <Import Project="Sdk.props" Sdk="Aspire.Hosting.Sdk" Version="9.1.0" />
@@ -1591,6 +1599,10 @@ public void Api_Diagnostic_01()
                         <ArtifactsPath>/artifacts</ArtifactsPath>
                       </PropertyGroup>
 
+                      <ItemGroup>
+                        <Clean Include="/artifacts/*" />
+                      </ItemGroup>
+
                       <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
                       <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
 
@@ -1658,6 +1670,10 @@ public void Api_Diagnostic_02()
                         <ArtifactsPath>/artifacts</ArtifactsPath>
                       </PropertyGroup>
 
+                      <ItemGroup>
+                        <Clean Include="/artifacts/*" />
+                      </ItemGroup>
+
                       <!-- We need to explicitly import Sdk props/targets so we can override the targets below. -->
                       <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />