diff --git a/docs/reference/docfx-cli-reference/docfx-clean.md b/docs/reference/docfx-cli-reference/docfx-clean.md
new file mode 100644
index 00000000000..0b1dc8d0d15
--- /dev/null
+++ b/docs/reference/docfx-cli-reference/docfx-clean.md
@@ -0,0 +1,44 @@
+# docfx clean
+
+## Name
+
+`docfx clean [OPTIONS]` - Cleanup temporary files that are generated by docfx.
+
+## Usage
+
+```pwsh
+docfx clean [OPTIONS]
+```
+
+Run `docfx clean --help` or `docfx -h` to get a list of all available options.
+
+## Arguments
+
+- `[config]` optional
+
+ Specify the path to the docfx configuration file.
+ By default, the `docfx.json' file path is used.
+
+## Options
+
+- **-h|--help**
+
+ Prints help information
+
+- **--dryRun**
+
+ If set to true, Skip actual file deletion.
+
+## Examples
+
+- Cleanup temporary files that are generated by docfx.
+
+```pwsh
+docfx clean
+```
+
+- Print a list of the files expected to be deleted.
+
+```pwsh
+docfx clean --dryRun --verbose
+```
diff --git a/docs/reference/docfx-cli-reference/overview.md b/docs/reference/docfx-cli-reference/overview.md
index 202e75618e3..31598d00f39 100644
--- a/docs/reference/docfx-cli-reference/overview.md
+++ b/docs/reference/docfx-cli-reference/overview.md
@@ -36,6 +36,7 @@ Generating offline documentation such as **PDF** is also supported.
| [docfx build](docfx-build.md) | Generate static site contents from input files. |
| [docfx pdf](docfx-pdf.md) | Generate pdf file. |
| [docfx serve](docfx-serve.md) | Host a local static website. |
+| [docfx clean](docfx-clean.md) | Cleanup temporary files that are generated by docfx. |
| [docfx init](docfx-init.md) | Generate an initial docfx.json following the instructions. |
| [docfx template](docfx-template.md) | List available templates or export template files. |
| [docfx download](docfx-download.md) | Download remote xref map file and create an xref archive(`.zip`) in local. |
diff --git a/docs/reference/docfx-cli-reference/toc.yml b/docs/reference/docfx-cli-reference/toc.yml
index 92548146a54..2400e3300da 100644
--- a/docs/reference/docfx-cli-reference/toc.yml
+++ b/docs/reference/docfx-cli-reference/toc.yml
@@ -4,6 +4,7 @@
- href: docfx-build.md
- href: docfx-pdf.md
- href: docfx-serve.md
+- href: docfx-clean.md
- href: docfx-init.md
- href: docfx-template.md
- href: docfx-download.md
diff --git a/src/Docfx.App/Config/CleanJsonConfig.cs b/src/Docfx.App/Config/CleanJsonConfig.cs
new file mode 100644
index 00000000000..ff883a1ff4e
--- /dev/null
+++ b/src/Docfx.App/Config/CleanJsonConfig.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+#nullable enable
+
+namespace Docfx;
+
+internal class CleanJsonConfig
+{
+ ///
+ /// If set to true, skip file/directory delete operations.
+ ///
+ [JsonProperty("dryRun")]
+ [JsonPropertyName("dryRun")]
+ public bool? DryRun { get; set; }
+}
diff --git a/src/Docfx.App/Models/RunCleanContext.cs b/src/Docfx.App/Models/RunCleanContext.cs
new file mode 100644
index 00000000000..d7a1617556a
--- /dev/null
+++ b/src/Docfx.App/Models/RunCleanContext.cs
@@ -0,0 +1,57 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Docfx;
+
+#nullable enable
+
+///
+/// Clean command context class to execute .
+ ///
+ public void IncrementDeletedFilesCount() => Interlocked.Increment(ref _deletedFilesCount);
+
+ ///
+ /// Increment .
+ ///
+ public void IncrementSkippedFilesCount() => Interlocked.Increment(ref _skippedFilesCount);
+}
diff --git a/src/Docfx.App/RunClean.cs b/src/Docfx.App/RunClean.cs
new file mode 100644
index 00000000000..4dd1e9d8cec
--- /dev/null
+++ b/src/Docfx.App/RunClean.cs
@@ -0,0 +1,176 @@
+// 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 Docfx.Common;
+
+#nullable enable
+
+namespace Docfx;
+
+///
+/// Helper class to cleanup docfx temporary files.
+///
+internal static class RunClean
+{
+ private const string SearchPattern = "*";
+ private static readonly EnumerationOptions DefaultEnumerationOptions = new()
+ {
+ MatchType = MatchType.Simple,
+ RecurseSubdirectories = false,
+ IgnoreInaccessible = true,
+ };
+
+ private static readonly StringComparison PathStringComparer = PathUtility.IsPathCaseInsensitive()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+
+ ///
+ /// Cleanup docfx temporary files/directories.
+ ///
+ public static void Exec(RunCleanContext context, CancellationToken cancellationToken = default)
+ {
+ Logger.LogInfo("Clean operation started...");
+
+ var startingTimestamp = Stopwatch.GetTimestamp();
+
+ // Cleanup build output directory.
+ var buildOutputDir = context.BuildOutputDirectory;
+ if (!string.IsNullOrEmpty(buildOutputDir))
+ {
+ Logger.LogInfo($"Running clean operation on build output directory: {buildOutputDir}");
+ CleanDirectoryContents(buildOutputDir, context, cancellationToken);
+ }
+
+ // Cleanup metadata output directories.
+ foreach (var metadataOutputDir in context.MetadataOutputDirectories)
+ {
+ Logger.LogInfo($"Running clean operation on metadata output directory: {metadataOutputDir}");
+ CleanDirectoryContents(metadataOutputDir, context, cancellationToken);
+ }
+
+ var elapsedSec = Stopwatch.GetElapsedTime(startingTimestamp).TotalSeconds;
+ Logger.LogInfo($"Clean: {context.DeletedFilesCount} files are deleted, {context.SkippedFilesCount} files are skipped.");
+ }
+
+ ///
+ /// Delete specified directory contents.
+ ///
+ private static void CleanDirectoryContents(string directoryPath, RunCleanContext context, CancellationToken cancellationToken = default)
+ {
+ Debug.Assert(Path.IsPathFullyQualified(directoryPath));
+
+ if (!IsUnderConfigDirectoryPath(directoryPath, context.ConfigDirectory))
+ {
+ Logger.LogWarning($"Clean operation is not supported if the output directory is not located within a base directory that contains a `docfx.json`. path: {Path.GetFullPath(directoryPath)}");
+ return;
+ }
+
+ var dirInfo = new DirectoryInfo(directoryPath);
+ if (!dirInfo.Exists)
+ return; // Skip if specified path is not exists.
+
+ var configDirectory = context.ConfigDirectory;
+
+ // Delete sub directories
+ foreach (var subDirInfo in dirInfo.EnumerateDirectories(SearchPattern, DefaultEnumerationOptions))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ DeleteDirectoryCore(subDirInfo, context, cancellationToken);
+ }
+
+ // Delete directory files
+ foreach (var fileInfo in dirInfo.EnumerateFiles(SearchPattern, DefaultEnumerationOptions))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ DeleteFileCore(fileInfo, context);
+ }
+ }
+
+ ///
+ /// Delete specified directory recursively.
+ ///
+ private static void DeleteDirectoryCore(DirectoryInfo dirInfo, RunCleanContext context, CancellationToken cancellationToken)
+ {
+ // Skip directory deletion, if specified directory have LinkTarget (SymbolicLink/DirectoryJunction).
+ // Because it might cause unexpected deletion of file/directory or it might cause infinite loop.
+ if (dirInfo.LinkTarget != null)
+ {
+ Logger.LogWarning("Enumeration of directory contents is skipped. Because it has LinkTarget. Path: " + dirInfo.FullName);
+ return;
+ }
+
+ // Delete sub directories
+ foreach (var subDirInfo in dirInfo.EnumerateDirectories(SearchPattern, DefaultEnumerationOptions))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ DeleteDirectoryCore(subDirInfo, context, cancellationToken);
+ }
+
+ // Delete files
+ foreach (var fileInfo in dirInfo.EnumerateFiles(SearchPattern, DefaultEnumerationOptions))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ DeleteFileCore(fileInfo, context);
+ }
+
+ if (context.DryRun)
+ return;
+
+ // Try to delete root directory if there are no remaining files.
+ if (dirInfo.GetFileSystemInfos("*", DefaultEnumerationOptions).Length == 0)
+ {
+ try
+ {
+ dirInfo.Delete(recursive: false);
+ }
+ catch
+ {
+ Logger.LogWarning("Skipped (Failed to delete): " + dirInfo.FullName);
+ // Ignore exception. (File is being used by another process, has no permissions, or has readonly attribute)
+ }
+ }
+ }
+
+ private static void DeleteFileCore(FileInfo fileInfo, RunCleanContext context)
+ {
+ if (context.DryRun)
+ {
+ Logger.LogVerbose("Skipped: " + fileInfo.FullName);
+ context.IncrementSkippedFilesCount();
+ return;
+ }
+
+ if (fileInfo.LinkTarget != null)
+ {
+ Logger.LogWarning("File delete operation is skipped. Because it has LinkTarget. Path: " + fileInfo.FullName);
+ context.IncrementSkippedFilesCount();
+ return;
+ }
+
+ try
+ {
+ fileInfo.Delete();
+ context.IncrementDeletedFilesCount();
+ }
+ catch
+ {
+ // File is being used by another process, has no permissions, or has readonly attribute.
+ context.IncrementSkippedFilesCount();
+ Logger.LogWarning("Skipped (Failed to delete): " + fileInfo.FullName);
+ }
+ }
+
+ private static bool IsUnderConfigDirectoryPath(string targetPath, string basePath)
+ {
+ // Normalize paths
+ basePath = Path.GetFullPath(basePath).TrimEnd(Path.DirectorySeparatorChar);
+ targetPath = Path.GetFullPath(targetPath);
+
+ // Try to append directory separator for string comparison.
+ if (!targetPath.EndsWith(Path.DirectorySeparatorChar))
+ targetPath += Path.DirectorySeparatorChar;
+
+ return targetPath.StartsWith(basePath, PathStringComparer);
+ }
+}
diff --git a/src/docfx/Models/CleanCommand.cs b/src/docfx/Models/CleanCommand.cs
new file mode 100644
index 00000000000..30355e8cddd
--- /dev/null
+++ b/src/docfx/Models/CleanCommand.cs
@@ -0,0 +1,70 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Spectre.Console.Cli;
+
+#nullable enable
+
+namespace Docfx;
+
+internal class CleanCommand : Command
+{
+ public override int Execute(CommandContext context, CleanCommandOptions settings, CancellationToken cancellationToken)
+ {
+ return CommandHelper.Run(settings, () =>
+ {
+ // Gets docfx config path.
+ var configPath = string.IsNullOrEmpty(settings.ConfigFile)
+ ? DataContracts.Common.Constants.ConfigFileName
+ : settings.ConfigFile!;
+ configPath = Path.GetFullPath(configPath);
+
+ // Load configs
+ var (config, baseDirectory) = Docset.GetConfig(configPath);
+
+ // Gets output directories
+ var buildOutputDirectory = GetBuildOutputDirectory(config, baseDirectory);
+ var metadataOutputDirectories = GetMetadataOutputDirectories(config, baseDirectory);
+
+ RunClean.Exec(new RunCleanContext
+ {
+ ConfigDirectory = Path.GetDirectoryName(configPath)!,
+ BuildOutputDirectory = buildOutputDirectory,
+ MetadataOutputDirectories = metadataOutputDirectories,
+ DryRun = settings.DryRun,
+ });
+ });
+ }
+
+ ///
+ /// Gets output directory of `docfx build` command.
+ ///
+ internal static string GetBuildOutputDirectory(DocfxConfig config, string baseDirectory)
+ {
+ var buildConfig = config.build;
+ if (buildConfig == null)
+ return "";
+
+ // Combine path
+ var outputDirectory = Path.Combine(baseDirectory, buildConfig.Output ?? buildConfig.Dest ?? "");
+
+ // Normalize to full path
+ return Path.GetFullPath(outputDirectory);
+ }
+
+ ///
+ /// Gets output directories of `docfx metadata` command.
+ ///
+ internal static string[] GetMetadataOutputDirectories(DocfxConfig config, string baseDirectory)
+ {
+ var metadataConfig = config.metadata;
+ if (metadataConfig == null)
+ return [];
+
+ return metadataConfig.Select(x =>
+ {
+ var outputDirectory = Path.Combine(baseDirectory, x.Output ?? x.Dest ?? "");
+ return Path.GetFullPath(outputDirectory);
+ }).ToArray();
+ }
+}
diff --git a/src/docfx/Models/CleanCommandOptions.cs b/src/docfx/Models/CleanCommandOptions.cs
new file mode 100644
index 00000000000..6b6817835db
--- /dev/null
+++ b/src/docfx/Models/CleanCommandOptions.cs
@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel;
+using Spectre.Console.Cli;
+
+#nullable enable
+
+namespace Docfx;
+
+[Description("Cleanup temporary files that are generated by docfx")]
+internal class CleanCommandOptions : LogOptions
+{
+ [Description("Path to docfx.json")]
+ [CommandArgument(0, "[config]")]
+ public required string ConfigFile { get; set; }
+
+ [Description("If set to true, skip file/directory delete operations")]
+ [CommandOption("--dryRun")]
+ public bool DryRun { get; set; }
+}
diff --git a/src/docfx/Program.cs b/src/docfx/Program.cs
index b71bb354f17..67780c1e90f 100644
--- a/src/docfx/Program.cs
+++ b/src/docfx/Program.cs
@@ -27,6 +27,7 @@ internal static int Main(string[] args)
config.AddCommand("metadata");
config.AddCommand("serve");
config.AddCommand("pdf");
+ config.AddCommand("clean");
config.AddBranch("template", template =>
{
template.AddCommand("list");
diff --git a/src/docfx/Properties/launchSettings.json b/src/docfx/Properties/launchSettings.json
index 44553193297..9cb115be065 100644
--- a/src/docfx/Properties/launchSettings.json
+++ b/src/docfx/Properties/launchSettings.json
@@ -26,6 +26,14 @@
"environmentVariables": {
}
},
+ // Run `docfx clean` command.
+ "docfx clean": {
+ "commandName": "Project",
+ "commandLineArgs": "clean ../../samples/seed/docfx.json",
+ "workingDirectory": ".",
+ "environmentVariables": {
+ }
+ },
// Run `docfx serve` command and launch browser.
"docfx serve": {
"commandName": "Project",
diff --git a/test/docfx.Tests/CleanCommandTest.cs b/test/docfx.Tests/CleanCommandTest.cs
new file mode 100644
index 00000000000..b7ed731238d
--- /dev/null
+++ b/test/docfx.Tests/CleanCommandTest.cs
@@ -0,0 +1,126 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using AwesomeAssertions;
+using Docfx.Common;
+using Docfx.Tests.Common;
+
+#nullable enable
+
+namespace Docfx.Tests;
+
+[Collection("docfx STA")]
+public class CleanCommandTest : TestBase
+{
+ private readonly string projectFolder;
+
+ public CleanCommandTest()
+ {
+ projectFolder = GetRandomFolder();
+ }
+
+ [Fact]
+ public async Task TestCleanCommand()
+ {
+ // Arrange
+ var outputDir = Path.Combine(projectFolder, "_site");
+ var metadataDir = Path.Combine(projectFolder, "obj");
+ Directory.CreateDirectory(outputDir);
+ Directory.CreateDirectory(metadataDir);
+
+ File.Copy("Assets/docfx.sample.1.json", Path.Combine(projectFolder, "docfx.json"));
+ File.Copy("Assets/filter.yaml.sample", Path.Combine(outputDir, "sample.md"));
+ File.Copy("Assets/test.cs.sample.1", Path.Combine(metadataDir, "sample.yml"));
+
+ var context = new RunCleanContext
+ {
+ ConfigDirectory = projectFolder,
+ BuildOutputDirectory = outputDir,
+ MetadataOutputDirectories = [metadataDir],
+ };
+
+ // Act
+ RunClean.Exec(context);
+
+ // Assert
+ context.DeletedFilesCount.Should().Be(2);
+ context.SkippedFilesCount.Should().Be(0);
+
+ Directory.GetFileSystemEntries(outputDir).Should().BeEmpty();
+ Directory.GetFileSystemEntries(metadataDir).Should().BeEmpty();
+
+ }
+
+ [Fact]
+ public async Task TestCleanCommand_WithDryRun()
+ {
+ // Arrange
+ var outputDir = Path.Combine(projectFolder, "_site");
+ var metadataDir = Path.Combine(projectFolder, "obj");
+ Directory.CreateDirectory(outputDir);
+ Directory.CreateDirectory(metadataDir);
+
+ File.Copy("Assets/docfx.sample.1.json", Path.Combine(projectFolder, "docfx.json"));
+ File.Copy("Assets/filter.yaml.sample", Path.Combine(outputDir, "sample.md"));
+ File.Copy("Assets/test.cs.sample.1", Path.Combine(metadataDir, "sample.yml"));
+
+ var context = new RunCleanContext
+ {
+ ConfigDirectory = projectFolder,
+ BuildOutputDirectory = outputDir,
+ MetadataOutputDirectories = [metadataDir],
+ DryRun = true,
+ };
+
+ // Act
+ RunClean.Exec(context);
+
+ // Assert
+ context.DeletedFilesCount.Should().Be(0);
+ context.SkippedFilesCount.Should().Be(2);
+
+ Directory.GetFileSystemEntries(outputDir).Should().HaveCount(1);
+ Directory.GetFileSystemEntries(metadataDir).Should().HaveCount(1);
+ }
+
+ [Fact]
+ public async Task TestCleanCommand_WithExternalDirectory()
+ {
+ // Arrange
+ using var listener = new TestLoggerListener();
+ Logger.RegisterListener(listener);
+ try
+ {
+ var tempDir = GetRandomFolder();
+ var outputDir = Path.Combine(tempDir, "_site");
+ var metadataDir = Path.Combine(tempDir, "obj");
+ Directory.CreateDirectory(outputDir);
+ Directory.CreateDirectory(metadataDir);
+
+ File.Copy("Assets/docfx.sample.1.json", Path.Combine(projectFolder, "docfx.json"));
+ File.Copy("Assets/filter.yaml.sample", Path.Combine(outputDir, "sample.md"));
+ File.Copy("Assets/test.cs.sample.1", Path.Combine(metadataDir, "sample.yml"));
+
+ var context = new RunCleanContext
+ {
+ ConfigDirectory = projectFolder,
+ BuildOutputDirectory = outputDir,
+ MetadataOutputDirectories = [metadataDir],
+ DryRun = true,
+ };
+
+ // Act
+ RunClean.Exec(context);
+
+ // Assert
+ listener.Items.Where(x => x.LogLevel == LogLevel.Warning).Should().HaveCount(2);
+ context.DeletedFilesCount.Should().Be(0);
+ context.SkippedFilesCount.Should().Be(0);
+ }
+ finally
+ {
+ Logger.UnregisterListener(listener);
+ Logger.ResetCount();
+ }
+ }
+}
diff --git a/test/docfx.Tests/CommandLineTest.cs b/test/docfx.Tests/CommandLineTest.cs
index 66abb6b1288..e667ec4e62b 100644
--- a/test/docfx.Tests/CommandLineTest.cs
+++ b/test/docfx.Tests/CommandLineTest.cs
@@ -24,6 +24,7 @@ public static void PrintsHelp()
Assert.Equal(0, Program.Main(["serve", "--help"]));
Assert.Equal(0, Program.Main(["metadata", "--help"]));
Assert.Equal(0, Program.Main(["pdf", "--help"]));
+ Assert.Equal(0, Program.Main(["clean", "--help"]));
Assert.Equal(0, Program.Main(["init", "--help"]));
Assert.Equal(0, Program.Main(["download", "--help"]));
Assert.Equal(0, Program.Main(["merge", "--help"]));