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"];