diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index 11bf5a1..8c00457 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -38,7 +38,7 @@ jobs: Pack: strategy: matrix: - job-runs-on: [ windows-latest, ubuntu-latest, macos-latest ] + job-runs-on: [ windows-latest, windows-11-arm, ubuntu-latest, ubuntu-24.04-arm, macos-15-intel, macos-latest ] runs-on: ${{ matrix.job-runs-on }} steps: @@ -82,12 +82,30 @@ jobs: name: Invex.Tools.ArtifactClean-windows-latest path: "${{ github.workspace }}/.github/artifacts/Invex.Tools.ArtifactClean" + - name: Download Invex.Tools.ArtifactClean + uses: actions/download-artifact@v4 + with: + name: Invex.Tools.ArtifactClean-windows-11-arm + path: "${{ github.workspace }}/.github/artifacts/Invex.Tools.ArtifactClean" + - name: Download Invex.Tools.ArtifactClean uses: actions/download-artifact@v4 with: name: Invex.Tools.ArtifactClean-ubuntu-latest path: "${{ github.workspace }}/.github/artifacts/Invex.Tools.ArtifactClean" + - name: Download Invex.Tools.ArtifactClean + uses: actions/download-artifact@v4 + with: + name: Invex.Tools.ArtifactClean-ubuntu-24.04-arm + path: "${{ github.workspace }}/.github/artifacts/Invex.Tools.ArtifactClean" + + - name: Download Invex.Tools.ArtifactClean + uses: actions/download-artifact@v4 + with: + name: Invex.Tools.ArtifactClean-macos-15-intel + path: "${{ github.workspace }}/.github/artifacts/Invex.Tools.ArtifactClean" + - name: Download Invex.Tools.ArtifactClean uses: actions/download-artifact@v4 with: @@ -122,12 +140,30 @@ jobs: name: Invex.Tools.ArtifactClean-windows-latest path: "${{ github.workspace }}/.github/artifacts/Invex.Tools.ArtifactClean" + - name: Download Invex.Tools.ArtifactClean + uses: actions/download-artifact@v4 + with: + name: Invex.Tools.ArtifactClean-windows-11-arm + path: "${{ github.workspace }}/.github/artifacts/Invex.Tools.ArtifactClean" + - name: Download Invex.Tools.ArtifactClean uses: actions/download-artifact@v4 with: name: Invex.Tools.ArtifactClean-ubuntu-latest path: "${{ github.workspace }}/.github/artifacts/Invex.Tools.ArtifactClean" + - name: Download Invex.Tools.ArtifactClean + uses: actions/download-artifact@v4 + with: + name: Invex.Tools.ArtifactClean-ubuntu-24.04-arm + path: "${{ github.workspace }}/.github/artifacts/Invex.Tools.ArtifactClean" + + - name: Download Invex.Tools.ArtifactClean + uses: actions/download-artifact@v4 + with: + name: Invex.Tools.ArtifactClean-macos-15-intel + path: "${{ github.workspace }}/.github/artifacts/Invex.Tools.ArtifactClean" + - name: Download Invex.Tools.ArtifactClean uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/Validate.yml b/.github/workflows/Validate.yml index 77a01ed..2fdac8d 100644 --- a/.github/workflows/Validate.yml +++ b/.github/workflows/Validate.yml @@ -33,7 +33,7 @@ jobs: Pack: strategy: matrix: - job-runs-on: [ windows-latest, ubuntu-latest, macos-latest ] + job-runs-on: [ windows-latest, windows-11-arm, ubuntu-latest, ubuntu-24.04-arm, macos-15-intel, macos-latest ] runs-on: ${{ matrix.job-runs-on }} steps: diff --git a/Invex.Tools.ArtifactClean/Commands.cs b/Invex.Tools.ArtifactClean/Commands.cs index 9baad92..1d7a1d6 100644 --- a/Invex.Tools.ArtifactClean/Commands.cs +++ b/Invex.Tools.ArtifactClean/Commands.cs @@ -4,129 +4,161 @@ public sealed class Commands { private static readonly EnumerationOptions EnumerationOptions = new() { + // Handle recursion manually for better control RecurseSubdirectories = false, + + // Skip symlinks/junctions to avoid infinite loops AttributesToSkip = FileAttributes.ReparsePoint, + + // Continue on permission errors (handled in catch block) IgnoreInaccessible = true, + + // Skip "." and ".." entries ReturnSpecialDirectories = false, + + // Faster matching since we use "*" pattern (not complex wildcards) + MatchType = MatchType.Simple, }; /// - /// Runs 'dotnet clean', then recursively deletes 'bin' and 'obj' directories from the specified path and optionally - /// restores the project. + /// Runs 'dotnet clean', then recursively deletes 'bin' and 'obj' directories from the specified path, then + /// optionally restores the project. /// /// -p, The root path to start cleaning from. [Default: Current directory] /// -n, If true, skips any restore operations. [Default: false] + /// -v, If true, outputs detailed information about the cleaning process. [Default: false] [PublicAPI] [Command("")] - [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Used by ConsoleAppFramework")] - public void Clean([HideDefaultValue] string? path = null, bool noRestore = false) + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Required by ConsoleAppFramework")] + public void Clean([HideDefaultValue] string? path = null, bool noRestore = false, bool verbose = false) { path ??= Directory.GetCurrentDirectory(); if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path)) - return; - - DotnetClean(); - CleanRecursive(path); - - if (noRestore) - return; - - var restoreProcessStartInfo = new ProcessStartInfo("dotnet", "restore") { - WorkingDirectory = path, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - using var process = Process.Start(restoreProcessStartInfo); - - if (process == null) - { - Console.Error.WriteLine("Failed to start 'dotnet restore' process."); + Console.Error.WriteLine($"Invalid or non-existent path: {path}"); return; } - process.WaitForExit(); + RunDotnetCommand("clean", path, verbose); - if (process.ExitCode == 0) - { - Console.WriteLine("'dotnet restore' completed successfully."); - } - else - { - Console.Error.WriteLine($"'dotnet restore' failed with exit code {process.ExitCode}."); - Console.Error.WriteLine(process.StandardError.ReadToEnd()); - } + // Track deleted directories across recursive calls using ref parameter + var deletedDirectoryCount = 0; + + CleanRecursive(path, verbose, ref deletedDirectoryCount); + Console.WriteLine($"Deleted {deletedDirectoryCount} 'bin' / 'obj' directories."); + + if (!noRestore) + RunDotnetCommand("restore", path, verbose); } - private static void DotnetClean() + private static void RunDotnetCommand(string command, string path, bool verbose) { - var cleanProcessStartInfo = new ProcessStartInfo("dotnet", "clean") + var processStartInfo = new ProcessStartInfo("dotnet", command) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, + WorkingDirectory = path, }; - using var process = Process.Start(cleanProcessStartInfo); + using var process = new Process(); + process.StartInfo = processStartInfo; - if (process == null) + // Always subscribe to output events, even in non-verbose mode + // This is required because we always call BeginOutputReadLine below + process.OutputDataReceived += (_, e) => { - Console.Error.WriteLine("Failed to start 'dotnet clean' process."); + if (e.Data != null && verbose) + Console.WriteLine(e.Data); + }; - return; - } + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null && verbose) + Console.Error.WriteLine(e.Data); + }; + + process.Start(); + + // Should always drain output streams to prevent buffer deadlock + // When RedirectStandardOutput/Error = true but streams aren't consumed, + // the process buffer can fill and hang. BeginOutputReadLine() drains asynchronously. + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); process.WaitForExit(); - if (process.ExitCode == 0) - { - Console.WriteLine("'dotnet clean' completed successfully."); - } - else - { - Console.Error.WriteLine($"'dotnet clean' failed with exit code {process.ExitCode}."); - Console.Error.WriteLine(process.StandardError.ReadToEnd()); - } + if (process.ExitCode != 0) + Console.Error.WriteLine($"'dotnet {command}' failed with exit code {process.ExitCode}."); + else if (!verbose) + Console.WriteLine($"'dotnet {command}' completed successfully."); } - private static void CleanRecursive(string path) + private static void CleanRecursive(string path, bool verbose, ref int deletedDirectoryCount) { try { - foreach (var directory in Directory.EnumerateDirectories(path, "*", EnumerationOptions)) + var directories = Directory + .EnumerateDirectories(path, "*", EnumerationOptions) + .ToArray(); + + var directoriesToDelete = new List(4); + + // First pass: identify bin/obj directories + foreach (var directory in directories) { + // Use Span to avoid string allocation when getting directory name var name = Path.GetFileName(directory.AsSpan()); - // Fast comparison using Span if (name.Equals("bin", StringComparison.OrdinalIgnoreCase) || name.Equals("obj", StringComparison.OrdinalIgnoreCase)) - DeleteDirectory(directory); - else - CleanRecursive(directory); + directoriesToDelete.Add(directory); } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // Ignore directories that cannot be accessed - } - } - private static void DeleteDirectory(string path) - { - try - { - Directory.Delete(path, true); - Console.WriteLine($"Deleted: {path}"); + // Delete bin/obj directories in parallel for better performance + if (directoriesToDelete.Count > 0) + { + // Use local counter for thread-safe parallel increments + var localDeleteCount = 0; + + Parallel.ForEach(directoriesToDelete, + directory => + { + try + { + // Delete recursively (true parameter) to remove all contents + Directory.Delete(directory, true); + Interlocked.Increment(ref localDeleteCount); + + if (verbose) + Console.WriteLine($"Deleted: {directory}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Always report deletion failures (unlike traversal errors, user should know) + Console.Error.WriteLine($"Failed to delete {directory}: {ex.Message}"); + } + }); + + // Add local count to the total + deletedDirectoryCount += localDeleteCount; + } + + // Second pass: recurse into other directories (sequential to avoid race conditions) + // Skip if we already marked this for deletion in first pass + foreach (var directory in directories) + if (!directoriesToDelete.Contains(directory)) + CleanRecursive(directory, verbose, ref deletedDirectoryCount); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - Console.Error.WriteLine($"Failed to delete {path}: {ex.Message}"); + // Catch specific exceptions: permission denied, path too long, etc. + // Silently continue unless verbose mode (don't interrupt bulk cleaning) + if (verbose) + Console.Error.WriteLine($"Skipped inaccessible directory: {path} - {ex.Message}"); } } } diff --git a/Invex.Tools.ArtifactClean/Invex.Tools.ArtifactClean.csproj b/Invex.Tools.ArtifactClean/Invex.Tools.ArtifactClean.csproj index 8e6c90f..f1620fb 100644 --- a/Invex.Tools.ArtifactClean/Invex.Tools.ArtifactClean.csproj +++ b/Invex.Tools.ArtifactClean/Invex.Tools.ArtifactClean.csproj @@ -14,10 +14,11 @@ net8.0;net10.0 + true - win-x64;linux-x64;osx-x64 + win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 true @@ -30,7 +31,8 @@ true true true - Size + Speed + false diff --git a/README.md b/README.md index c68ba9d..5f24958 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,14 @@ A collection of useful .NET tools for developers. ### ArtifactClean (`artclean`) -`artclean` is a high-performance, recursive cleaning tool for .NET projects. It identifies and removes `bin` and `obj` directories across your workspace to reclaim disk space or ensure a clean state for your builds. +`artclean` is a high-performance, recursive cleaning tool for .NET projects. It identifies and removes `bin` and `obj` +directories across your workspace to reclaim disk space or ensure a clean state for your builds. -By default, it automatically executes `dotnet restore` after cleaning to bring your projects back to a ready-to-build state. +By default, it automatically executes `dotnet restore` after cleaning to bring your projects back to a ready-to-build +state. #### Key Features + - **Fast Recursion**: Efficiently scans directories while ignoring reparse points. - **Deep Clean**: Removes both `bin` and `obj` folders. - **Auto-Restore**: Automatically runs `dotnet restore` to minimize downtime (can be disabled). @@ -19,20 +22,27 @@ By default, it automatically executes `dotnet restore` after cleaning to bring y #### Installation ```bash -dotnet tool install --global artclean +dotnet tool install --global Invex.Tools.ArtifactClean ``` #### Usage ```bash +# In the repo you want to clean +artclean + +# Or specify arguments / options: artclean [path] [options] ``` **Arguments:** + - `path`: The root directory to begin the recursive search. [Default: current directory] **Options:** + - `-n, --no-restore`: Skips the `dotnet restore` operation after cleaning. +- `-v, --verbose`: Enables verbose output during the cleaning process. --- diff --git a/_atom/Build.cs b/_atom/Build.cs index 11a7bcb..98f8e9c 100644 --- a/_atom/Build.cs +++ b/_atom/Build.cs @@ -7,7 +7,12 @@ internal partial class Build : BuildDefinition, IGithubWorkflows, IGitVersion, I { public static readonly string[] PlatformNames = [ - IJobRunsOn.WindowsLatestTag, IJobRunsOn.UbuntuLatestTag, IJobRunsOn.MacOsLatestTag, + IJobRunsOn.WindowsLatestTag, + "windows-11-arm", + IJobRunsOn.UbuntuLatestTag, + "ubuntu-24.04-arm", + "macos-15-intel", + IJobRunsOn.MacOsLatestTag, ]; public static readonly string[] FrameworkNames = ["net8.0", "net9.0", "net10.0"];