Merge branch 'phase/49-skip-stale-build'

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 08:51:57 +03:00
co-authored by Cursor
9 changed files with 497 additions and 16 deletions
@@ -0,0 +1,105 @@
namespace HSchool.AppHost.Tests;
/// <summary>
/// Same rules as <c>tools/apphost-uptodate.ps1</c>. Keep the two in sync.
/// </summary>
internal static class LaunchBuildStamp
{
public const string TargetFramework = "net10.0";
public static readonly string[] InputExtensions = [".cs", ".csproj", ".props"];
public static readonly string[] NamedInputs =
[
"global.json",
"Directory.Build.props",
"Directory.Packages.props",
];
public static readonly string[] SkipDirectoryNames = ["bin", "obj", "node_modules"];
public static readonly string[] OutputProjects = ["HSchool.AppHost", "HSchool.Server"];
public static bool NeedsBuild(string repoRoot, string configuration = "Debug")
{
ArgumentException.ThrowIfNullOrWhiteSpace(repoRoot);
ArgumentException.ThrowIfNullOrWhiteSpace(configuration);
DateTime? oldestOutput = null;
foreach (var project in OutputProjects)
{
var dll = Path.Combine(
repoRoot,
"src",
project,
"bin",
configuration,
TargetFramework,
project + ".dll");
if (!File.Exists(dll))
{
return true;
}
var written = File.GetLastWriteTimeUtc(dll);
if (oldestOutput is null || written < oldestOutput.Value)
{
oldestOutput = written;
}
}
if (oldestOutput is null)
{
return true;
}
var cutoff = oldestOutput.Value;
foreach (var name in NamedInputs)
{
var path = Path.Combine(repoRoot, name);
if (File.Exists(path) && File.GetLastWriteTimeUtc(path) > cutoff)
{
return true;
}
}
var src = Path.Combine(repoRoot, "src");
return !Directory.Exists(src) || HasNewerInput(src, cutoff);
}
private static bool HasNewerInput(string directory, DateTime cutoff)
{
foreach (var file in Directory.EnumerateFiles(directory))
{
if (!IsInputExtension(Path.GetExtension(file)))
{
continue;
}
if (File.GetLastWriteTimeUtc(file) > cutoff)
{
return true;
}
}
foreach (var child in Directory.EnumerateDirectories(directory))
{
var leaf = Path.GetFileName(child);
if (SkipDirectoryNames.Contains(leaf, StringComparer.OrdinalIgnoreCase))
{
continue;
}
if (HasNewerInput(child, cutoff))
{
return true;
}
}
return false;
}
private static bool IsInputExtension(string extension) =>
InputExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
@@ -0,0 +1,157 @@
namespace HSchool.AppHost.Tests;
public class LaunchBuildStampTests
{
private static readonly DateTime Inputs = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Outputs = new(2026, 1, 1, 12, 5, 0, DateTimeKind.Utc);
private static readonly DateTime Newer = new(2026, 1, 1, 12, 10, 0, DateTimeKind.Utc);
[Fact]
public void MissingDll_NeedsBuild()
{
using var repo = FreshRepo();
File.Delete(repo.Dll("HSchool.Server"));
Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void FreshDlls_SkipBuild()
{
using var repo = FreshRepo();
Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void NewerCs_NeedsBuild()
{
using var repo = FreshRepo();
repo.Touch("src/HSchool.Server/Program.cs", Newer);
Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void NewerTsOrJsonc_SkipBuild()
{
using var repo = FreshRepo();
repo.Write("src/HSchool.Client/src/main.ts", "export {}");
repo.Write("src/HSchool.Server/mods/core/defs/foo.jsonc", "{}");
repo.Touch("src/HSchool.Client/src/main.ts", Newer);
repo.Touch("src/HSchool.Server/mods/core/defs/foo.jsonc", Newer);
Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void FilesInBinObjNodeModules_AreNotInputs()
{
using var repo = FreshRepo();
repo.Write("src/HSchool.Server/bin/Debug/net10.0/Generated.cs", "class G {}");
repo.Write("src/HSchool.Server/obj/Debug/net10.0/Generated.cs", "class G {}");
repo.Write("src/HSchool.Client/node_modules/pkg/index.cs", "class N {}");
repo.Touch("src/HSchool.Server/bin/Debug/net10.0/Generated.cs", Newer);
repo.Touch("src/HSchool.Server/obj/Debug/net10.0/Generated.cs", Newer);
repo.Touch("src/HSchool.Client/node_modules/pkg/index.cs", Newer);
Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void NewerDirectoryPackagesProps_NeedsBuild()
{
using var repo = FreshRepo();
repo.Touch("Directory.Packages.props", Newer);
Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void ReleaseLooksAtReleaseBin()
{
using var repo = FreshRepo();
Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root, "Debug"));
Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root, "Release"));
repo.WriteDll("HSchool.AppHost", "Release");
repo.WriteDll("HSchool.Server", "Release");
repo.TouchDlls("Release", Outputs);
Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root, "Release"));
}
private static TempRepo FreshRepo()
{
var repo = new TempRepo();
repo.Write("global.json", "{}");
repo.Write("Directory.Build.props", "<Project />");
repo.Write("Directory.Packages.props", "<Project />");
repo.Write("src/HSchool.AppHost/AppHost.cs", "class A {}");
repo.Write("src/HSchool.AppHost/HSchool.AppHost.csproj", "<Project />");
repo.Write("src/HSchool.Server/Program.cs", "class S {}");
repo.Write("src/HSchool.Server/HSchool.Server.csproj", "<Project />");
repo.WriteDll("HSchool.AppHost");
repo.WriteDll("HSchool.Server");
repo.Touch("global.json", Inputs);
repo.Touch("Directory.Build.props", Inputs);
repo.Touch("Directory.Packages.props", Inputs);
repo.Touch("src/HSchool.AppHost/AppHost.cs", Inputs);
repo.Touch("src/HSchool.AppHost/HSchool.AppHost.csproj", Inputs);
repo.Touch("src/HSchool.Server/Program.cs", Inputs);
repo.Touch("src/HSchool.Server/HSchool.Server.csproj", Inputs);
repo.TouchDlls("Debug", Outputs);
return repo;
}
private sealed class TempRepo : IDisposable
{
public string Root { get; } = Path.Combine(Path.GetTempPath(), "h-school-stamp-" + Guid.NewGuid().ToString("N"));
public TempRepo() => Directory.CreateDirectory(Root);
public string Write(string relative, string contents)
{
var path = PathUnder(relative);
var dir = Path.GetDirectoryName(path);
if (dir is not null)
{
Directory.CreateDirectory(dir);
}
File.WriteAllText(path, contents);
return path;
}
public string WriteDll(string project, string configuration = "Debug") =>
Write($"src/{project}/bin/{configuration}/{LaunchBuildStamp.TargetFramework}/{project}.dll", "dll");
public string Dll(string project, string configuration = "Debug") =>
PathUnder($"src/{project}/bin/{configuration}/{LaunchBuildStamp.TargetFramework}/{project}.dll");
public void Touch(string relative, DateTime utc) => File.SetLastWriteTimeUtc(PathUnder(relative), utc);
public void TouchDlls(string configuration, DateTime utc)
{
foreach (var project in LaunchBuildStamp.OutputProjects)
{
Touch($"src/{project}/bin/{configuration}/{LaunchBuildStamp.TargetFramework}/{project}.dll", utc);
}
}
public void Dispose()
{
try
{
Directory.Delete(Root, recursive: true);
}
catch (IOException)
{
}
}
private string PathUnder(string relative) =>
Path.Combine(Root, relative.Replace('/', Path.DirectorySeparatorChar));
}
}