Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion .github/workflows/Build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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"

Comment on lines +85 to +108
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These download steps are highly repetitive and will keep growing as the matrix expands. With actions/download-artifact@v4, consider downloading via a single step using a pattern (e.g., Invex.Tools.ArtifactClean-*) and merge-multiple: true, or drive downloads via a small matrix/loop, to reduce duplication and the chance of missing future runner additions.

Copilot uses AI. Check for mistakes.
- name: Download Invex.Tools.ArtifactClean
uses: actions/download-artifact@v4
with:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/Validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
172 changes: 102 additions & 70 deletions Invex.Tools.ArtifactClean/Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

/// <summary>
/// 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.
/// </summary>
/// <param name="path">-p, The root path to start cleaning from. [Default: Current directory]</param>
/// <param name="noRestore">-n, If true, skips any restore operations. [Default: false]</param>
/// <param name="verbose">-v, If true, outputs detailed information about the cleaning process. [Default: false]</param>
[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();

Comment on lines +84 to 91
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process.Start() can throw (e.g., missing dotnet on PATH, invalid working directory, or start failures). Previously the code handled a null process; now this will crash the tool. Wrap the start + read initialization in a try/catch (e.g., for Win32Exception/InvalidOperationException) and emit a clear error to stderr before returning a non-zero outcome (or equivalent).

Suggested change
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();
try
{
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();
}
catch (System.ComponentModel.Win32Exception ex)
{
Console.Error.WriteLine($"Failed to start 'dotnet {command}' in '{path}': {ex.Message}");
return;
}
catch (InvalidOperationException ex)
{
Console.Error.WriteLine($"Failed to initialize output capture for 'dotnet {command}' in '{path}': {ex.Message}");
return;
}

Copilot uses AI. Check for mistakes.
process.WaitForExit();
Comment thread
DecSmith42 marked this conversation as resolved.

Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With asynchronous BeginOutputReadLine/BeginErrorReadLine, a single WaitForExit() can return before all output events are fully drained/processed, potentially truncating logs (especially in verbose mode). Consider ensuring async reads complete (e.g., a second WaitForExit() or equivalent pattern) before returning.

Suggested change
// Ensure asynchronous output/error events are fully drained before returning.
process.WaitForExit();

Copilot uses AI. Check for mistakes.
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.");
Comment thread
DecSmith42 marked this conversation as resolved.
}

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();
Comment on lines +104 to +106
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Materializing all subdirectories into an array at every recursion level can increase memory pressure and latency for large repos. You can avoid ToArray() by building two lists during a single enumeration pass (e.g., one for bin/obj deletion and one for recursion), which keeps streaming behavior and still supports the two-pass logic.

Copilot uses AI. Check for mistakes.

var directoriesToDelete = new List<string>(4);

// First pass: identify bin/obj directories
foreach (var directory in directories)
{
// Use Span<char> 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);
Comment thread
DecSmith42 marked this conversation as resolved.
}
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}");
}
}
}
6 changes: 4 additions & 2 deletions Invex.Tools.ArtifactClean/Invex.Tools.ArtifactClean.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@

<PropertyGroup Condition="$(RuntimeIdentifier) == ''">
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>

<PropertyGroup Condition="$(TargetFramework) == 'net10.0'">
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64</RuntimeIdentifiers>
<RuntimeIdentifiers>win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64</RuntimeIdentifiers>
<PublishAot>true</PublishAot>

<!-- Size optimisation bits for Native AOT -->
Expand All @@ -30,7 +31,8 @@
<InvariantGlobalization>true</InvariantGlobalization>
<IlcDisableReflection>true</IlcDisableReflection>
<IlcTrimMetadata>true</IlcTrimMetadata>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
<IlcOptimizationPreference>Speed</IlcOptimizationPreference>
<IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>
Comment thread
DecSmith42 marked this conversation as resolved.
</PropertyGroup>

<ItemGroup>
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Comment thread
DecSmith42 marked this conversation as resolved.
- `-v, --verbose`: Enables verbose output during the cleaning process.

---

Expand Down
7 changes: 6 additions & 1 deletion _atom/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
Loading