Skip to content

Commit 45946c6

Browse files
committed
Add 'clean' command and automatic cleanup of stale caches
- Add top-level 'go clean <file.cs>' to delete publish cache for a specific app - Add hidden 'cleanup' command (and CleanupCommands) for global stale dir pruning - Implement CleanupScheduler to trigger background cleanup after runs when due - Add AppCleaner, CleanupManager, Settings (Tomlyn) and DirectoryExtensions - Refactor to centralize publishDir and touch dirs on use for LRU cleanup - Add unit tests for the new cleanup functionality - Update help and add Tomlyn dependency
1 parent 8100c1a commit 45946c6

13 files changed

Lines changed: 488 additions & 17 deletions

src/Tests/AppCleanerTests.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using Devlooped;
2+
3+
namespace Tests;
4+
5+
public class AppCleanerTests
6+
{
7+
[Fact]
8+
public void Clean_deletes_publish_directory_when_present()
9+
{
10+
var root = CreateTempDir();
11+
var stamp = Path.Combine(root, "go.stamp");
12+
File.WriteAllText(stamp, "mode=aot");
13+
File.WriteAllText(Path.Combine(root, "app.exe"), "app");
14+
15+
var exit = AppCleaner.Clean(root, stamp);
16+
17+
Assert.Equal(0, exit);
18+
Assert.False(Directory.Exists(root));
19+
}
20+
21+
[Fact]
22+
public void Clean_deletes_stamp_when_publish_directory_delete_fails()
23+
{
24+
var root = CreateTempDir();
25+
var stamp = Path.Combine(root, "go.stamp");
26+
var locked = Path.Combine(root, "locked.exe");
27+
File.WriteAllText(stamp, "mode=aot");
28+
File.WriteAllText(locked, "locked");
29+
30+
using var stream = new FileStream(locked, FileMode.Open, FileAccess.Read, FileShare.None);
31+
32+
var exit = AppCleaner.Clean(root, stamp);
33+
34+
Assert.Equal(0, exit);
35+
Assert.True(Directory.Exists(root));
36+
Assert.False(File.Exists(stamp));
37+
}
38+
39+
[Fact]
40+
public void Clean_succeeds_when_publish_directory_is_missing()
41+
{
42+
var root = Path.Combine(Path.GetTempPath(), "go-tests-" + Guid.NewGuid().ToString("N"));
43+
var stamp = Path.Combine(root, "go.stamp");
44+
45+
var exit = AppCleaner.Clean(root, stamp);
46+
47+
Assert.Equal(0, exit);
48+
}
49+
50+
static string CreateTempDir()
51+
{
52+
var dir = Path.Combine(Path.GetTempPath(), "go-tests-" + Guid.NewGuid().ToString("N"));
53+
Directory.CreateDirectory(dir);
54+
return dir;
55+
}
56+
}

src/Tests/CleanupManagerTests.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using Devlooped;
2+
3+
namespace Tests;
4+
5+
public class CleanupManagerTests
6+
{
7+
[Fact]
8+
public void CleanupStaleDirectories_deletes_directories_older_than_cutoff()
9+
{
10+
var root = CreateTempDir();
11+
var stale = CreateSubDir(root, "stale");
12+
var fresh = CreateSubDir(root, "fresh");
13+
14+
Directory.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddDays(-5));
15+
Directory.SetLastWriteTimeUtc(fresh, DateTime.UtcNow);
16+
17+
CleanupManager.CleanupStaleDirectories(root, days: 2);
18+
19+
Assert.False(Directory.Exists(stale));
20+
Assert.True(Directory.Exists(fresh));
21+
}
22+
23+
[Fact]
24+
public void SettingsStore_roundtrips_last_cleanup_utc()
25+
{
26+
var root = CreateTempDir();
27+
var settingsPath = Path.Combine(root, "go.toml");
28+
29+
try
30+
{
31+
var expected = new DateTimeOffset(2026, 3, 15, 12, 30, 0, TimeSpan.Zero);
32+
SettingsStore.Save(new Settings { LastCleanupUtc = expected }, settingsPath);
33+
34+
var loaded = SettingsStore.Load(settingsPath);
35+
36+
Assert.Equal(expected, loaded.LastCleanupUtc);
37+
}
38+
finally
39+
{
40+
if (File.Exists(settingsPath))
41+
File.Delete(settingsPath);
42+
}
43+
}
44+
45+
static string CreateTempDir()
46+
{
47+
var dir = Path.Combine(Path.GetTempPath(), "go-tests-" + Guid.NewGuid().ToString("N"));
48+
Directory.CreateDirectory(dir);
49+
return dir;
50+
}
51+
52+
static string CreateSubDir(string root, string name)
53+
{
54+
var path = Path.Combine(root, name);
55+
Directory.CreateDirectory(path);
56+
return path;
57+
}
58+
}

src/Tests/CleanupSchedulerTests.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using Devlooped;
2+
3+
namespace Tests;
4+
5+
public class CleanupSchedulerTests
6+
{
7+
[Fact]
8+
public void ShouldRun_returns_true_when_last_cleanup_is_null()
9+
{
10+
var settingsPath = CreateSettingsPath();
11+
12+
try
13+
{
14+
SettingsStore.Save(new Settings(), settingsPath);
15+
16+
Assert.True(CleanupScheduler.ShouldRun(settingsPath: settingsPath));
17+
}
18+
finally
19+
{
20+
DeleteIfExists(settingsPath);
21+
}
22+
}
23+
24+
[Fact]
25+
public void ShouldRun_returns_true_when_last_cleanup_is_older_than_default_days()
26+
{
27+
var settingsPath = CreateSettingsPath();
28+
29+
try
30+
{
31+
SettingsStore.Save(new Settings
32+
{
33+
LastCleanupUtc = DateTimeOffset.UtcNow.AddDays(-3),
34+
}, settingsPath);
35+
36+
Assert.True(CleanupScheduler.ShouldRun(settingsPath: settingsPath));
37+
}
38+
finally
39+
{
40+
DeleteIfExists(settingsPath);
41+
}
42+
}
43+
44+
[Fact]
45+
public void ShouldRun_returns_false_when_last_cleanup_is_recent()
46+
{
47+
var settingsPath = CreateSettingsPath();
48+
49+
try
50+
{
51+
SettingsStore.Save(new Settings
52+
{
53+
LastCleanupUtc = DateTimeOffset.UtcNow.AddHours(-1),
54+
}, settingsPath);
55+
56+
Assert.False(CleanupScheduler.ShouldRun(settingsPath: settingsPath));
57+
}
58+
finally
59+
{
60+
DeleteIfExists(settingsPath);
61+
}
62+
}
63+
64+
static string CreateSettingsPath()
65+
{
66+
var dir = Path.Combine(Path.GetTempPath(), "go-tests-" + Guid.NewGuid().ToString("N"));
67+
Directory.CreateDirectory(dir);
68+
return Path.Combine(dir, "go.toml");
69+
}
70+
71+
static void DeleteIfExists(string path)
72+
{
73+
if (File.Exists(path))
74+
File.Delete(path);
75+
}
76+
}

src/go/AppCleaner.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace Devlooped;
2+
3+
public static class AppCleaner
4+
{
5+
public static int Clean(string publishDir, string stamp)
6+
{
7+
try
8+
{
9+
if (Directory.Exists(publishDir))
10+
Directory.Delete(publishDir, recursive: true);
11+
12+
return 0;
13+
}
14+
catch
15+
{
16+
try
17+
{
18+
if (File.Exists(stamp))
19+
File.Delete(stamp);
20+
21+
return 0;
22+
}
23+
catch
24+
{
25+
return 1;
26+
}
27+
}
28+
}
29+
}

src/go/CleanupCommands.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using ConsoleAppFramework;
2+
3+
namespace Devlooped;
4+
5+
public class CleanupCommands
6+
{
7+
[Command("cleanup")]
8+
[Hidden]
9+
public int Cleanup(int days = CleanupManager.DefaultDays) => CleanupManager.Cleanup(days);
10+
}

src/go/CleanupManager.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace Devlooped;
2+
3+
public static class CleanupManager
4+
{
5+
public const int DefaultDays = 2;
6+
7+
public static int Cleanup(int days = DefaultDays)
8+
{
9+
CleanupStaleDirectories(Directory.GetTempRoot(), days);
10+
11+
SettingsStore.Save(new Settings { LastCleanupUtc = DateTimeOffset.UtcNow });
12+
return 0;
13+
}
14+
15+
public static void CleanupStaleDirectories(string root, int days)
16+
{
17+
var cutoff = DateTime.UtcNow.AddDays(-days);
18+
19+
foreach (var directory in Directory.EnumerateDirectories(root))
20+
{
21+
if (Directory.GetLastWriteTimeUtc(directory) >= cutoff)
22+
continue;
23+
24+
Directory.Delete(directory, recursive: true);
25+
}
26+
}
27+
}

src/go/CleanupScheduler.cs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System.Diagnostics;
2+
using System.Reflection;
3+
4+
namespace Devlooped;
5+
6+
public static class CleanupScheduler
7+
{
8+
public static bool ShouldRun(int days = CleanupManager.DefaultDays, string? settingsPath = null)
9+
{
10+
var settings = SettingsStore.Load(settingsPath);
11+
if (settings.LastCleanupUtc is null)
12+
return true;
13+
14+
return settings.LastCleanupUtc < DateTimeOffset.UtcNow.AddDays(-days);
15+
}
16+
17+
public static void StartDetached()
18+
=> Process.Start(CreateCleanupProcessStartInfo());
19+
20+
public static Task TryScheduleAsync()
21+
{
22+
return Task.Run(() =>
23+
{
24+
try
25+
{
26+
if (!ShouldRun())
27+
return;
28+
29+
StartDetached();
30+
}
31+
catch
32+
{
33+
}
34+
});
35+
}
36+
37+
static ProcessStartInfo CreateCleanupProcessStartInfo()
38+
{
39+
var commandLine = Environment.GetCommandLineArgs();
40+
var fileName = Environment.ProcessPath ?? commandLine[0];
41+
42+
var startInfo = new ProcessStartInfo
43+
{
44+
FileName = fileName,
45+
UseShellExecute = false,
46+
CreateNoWindow = true,
47+
};
48+
49+
if (IsDotnetHost(fileName) &&
50+
commandLine.Length >= 3 &&
51+
commandLine[1] == "exec" &&
52+
commandLine[2].EndsWith("go.dll", StringComparison.OrdinalIgnoreCase))
53+
{
54+
startInfo.ArgumentList.Add("exec");
55+
startInfo.ArgumentList.Add(commandLine[2]);
56+
}
57+
else if (IsDotnetHost(fileName))
58+
{
59+
var assembly = Assembly.GetEntryAssembly()?.Location;
60+
if (!string.IsNullOrEmpty(assembly))
61+
{
62+
startInfo.ArgumentList.Add("exec");
63+
startInfo.ArgumentList.Add(assembly);
64+
}
65+
}
66+
67+
startInfo.ArgumentList.Add("cleanup");
68+
return startInfo;
69+
}
70+
71+
static bool IsDotnetHost(string path)
72+
=> Path.GetFileNameWithoutExtension(path).Equals("dotnet", StringComparison.OrdinalIgnoreCase);
73+
}

0 commit comments

Comments
 (0)