From 5c00c876060d2c81ced5d5c335e8d955d295dc5f Mon Sep 17 00:00:00 2001 From: filzrev <103790468+filzrev@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:08:30 +0900 Subject: [PATCH] feat: add `docfx clean` command --- .../docfx-cli-reference/docfx-clean.md | 44 +++++ .../reference/docfx-cli-reference/overview.md | 1 + docs/reference/docfx-cli-reference/toc.yml | 1 + src/Docfx.App/Config/CleanJsonConfig.cs | 19 ++ src/Docfx.App/Models/RunCleanContext.cs | 57 ++++++ src/Docfx.App/RunClean.cs | 176 ++++++++++++++++++ src/docfx/Models/CleanCommand.cs | 70 +++++++ src/docfx/Models/CleanCommandOptions.cs | 21 +++ src/docfx/Program.cs | 1 + src/docfx/Properties/launchSettings.json | 8 + test/docfx.Tests/CleanCommandTest.cs | 126 +++++++++++++ test/docfx.Tests/CommandLineTest.cs | 1 + 12 files changed, 525 insertions(+) create mode 100644 docs/reference/docfx-cli-reference/docfx-clean.md create mode 100644 src/Docfx.App/Config/CleanJsonConfig.cs create mode 100644 src/Docfx.App/Models/RunCleanContext.cs create mode 100644 src/Docfx.App/RunClean.cs create mode 100644 src/docfx/Models/CleanCommand.cs create mode 100644 src/docfx/Models/CleanCommandOptions.cs create mode 100644 test/docfx.Tests/CleanCommandTest.cs 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"]));