Skip to content

Commit 8ef09a5

Browse files
authored
Delete stale EmptyFiles marker when output missing (#200)
1 parent af5fb96 commit 8ef09a5

8 files changed

Lines changed: 150 additions & 43 deletions

File tree

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
How it works:
22

33
1. Inputs="$(MSBuildThisFileFile)" - Tracks the package file timestamp
4-
2. Outputs="$(EmptyFilesMarker)" - Tracks a marker file in the output directory
4+
2. Outputs="$(EmptyFilesMarker)" - Tracks a marker file in the intermediate output directory
55
3. MSBuild automatically skips the target if all outputs are newer than all inputs
66
4. SkipUnchangedFiles="true" - Additional optimization to skip individual unchanged files
77
5. Touch - Updates the marker file timestamp after successful copy
8+
6. DeleteEmptyFilesMarkerIfTargetMissing - If the EmptyFiles directory is missing from the output, deletes the marker file so the copy re-runs
89

910
This will only copy files when:
1011

1112
1. The targets is newer than the marker file, OR
12-
2. The marker file doesn't exist (first build/clean build)
13+
2. The marker file doesn't exist (first build/clean build), OR
14+
3. The EmptyFiles directory doesn't exist in the output (marker is deleted to force re-copy)
1315

1416
Benefits:
1517

1618
* Dramatically reduces IO on incremental builds
1719
* Works with dotnet clean (removes marker file)
20+
* Recovers when the output directory is cleaned without cleaning the intermediate directory
1821
* Still respects individual file changes via SkipUnchangedFiles

buildTransitive/EmptyFiles.targets

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
<EmptyFilesSourcePath>$(MSBuildThisFileDirectory)..\files</EmptyFilesSourcePath>
55
</PropertyGroup>
66

7+
<Target Name="DeleteEmptyFilesMarkerIfTargetMissing"
8+
BeforeTargets="CopyEmptyFilesIncremental"
9+
Condition="$(DesignTimeBuild) != true AND !Exists('$(TargetDir)EmptyFiles')">
10+
<Delete Files="$(IntermediateOutputPath)EmptyFiles.copied" />
11+
</Target>
12+
713
<Target Name="CopyEmptyFilesIncremental"
814
AfterTargets="Build"
915
Condition="$(DesignTimeBuild) != true"
@@ -29,4 +35,4 @@
2935

3036
<Touch Files="$(EmptyFilesMarker)" AlwaysCreate="true" />
3137
</Target>
32-
</Project>
38+
</Project>

src/Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
<PackageVersion Include="NUnit3TestAdapter" Version="5.2.0" Pinned="true" />
1111
<PackageVersion Include="ProjectDefaults" Version="1.0.171" />
1212
<PackageVersion Include="Polyfill" Version="9.8.1" />
13+
<PackageVersion Include="ProjectFiles" Version="0.5.0" />
1314
<PackageVersion Include="System.Memory" Version="4.6.3" />
1415
<PackageVersion Include="Microsoft.Sbom.Targets" Version="4.1.5" />
1516
<!-- Keep at 8 to support old frameworks -->
1617
<PackageVersion Include="System.Collections.Immutable" Version="8.0.0" Pinned="true" />
1718
<PackageVersion Include="System.ValueTuple" Version="4.5.0" Pinned="true" />
19+
<PackageVersion Include="Verify" Version="31.11.0" />
1820
</ItemGroup>
1921
</Project>

src/Tests/BuildTargetsTests.cs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
public class BuildTargetsTests
2+
{
3+
[Test]
4+
public async Task BuildCopiesEmptyFiles()
5+
{
6+
using var temp = new TempDirectory();
7+
8+
var (nugetSource, packageVersion) = FindPackageInfo();
9+
WriteCsproj(temp, packageVersion);
10+
WriteNugetConfig(temp, nugetSource);
11+
12+
var exitCode = await DotnetBuild(temp);
13+
That(exitCode, Is.EqualTo(0));
14+
15+
var emptyFilesDir = Path.Combine(temp.Path, "bin", "Release", "net10.0", "EmptyFiles");
16+
That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after build");
17+
That(Directory.GetFiles(emptyFilesDir, "*", SearchOption.AllDirectories), Is.Not.Empty, "EmptyFiles directory should contain files");
18+
}
19+
20+
[Test]
21+
public async Task RecoverFromDeletedEmptyFilesDirectory()
22+
{
23+
using var temp = new TempDirectory();
24+
25+
var (nugetSource, packageVersion) = FindPackageInfo();
26+
WriteCsproj(temp, packageVersion);
27+
WriteNugetConfig(temp, nugetSource);
28+
29+
// First build
30+
var exitCode = await DotnetBuild(temp);
31+
That(exitCode, Is.EqualTo(0));
32+
33+
var emptyFilesDir = Path.Combine(temp.Path, "bin", "Release", "net10.0", "EmptyFiles");
34+
That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after first build");
35+
36+
// Delete EmptyFiles directory from output (leave obj/ intact so marker file survives)
37+
Directory.Delete(emptyFilesDir, true);
38+
That(Directory.Exists(emptyFilesDir), Is.False);
39+
40+
// Second build — should recover
41+
exitCode = await DotnetBuild(temp);
42+
That(exitCode, Is.EqualTo(0));
43+
44+
That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after second build");
45+
That(Directory.GetFiles(emptyFilesDir, "*", SearchOption.AllDirectories), Is.Not.Empty, "EmptyFiles directory should contain files after recovery");
46+
}
47+
48+
static void WriteCsproj(TempDirectory temp, string version)
49+
{
50+
var csproj = $"""
51+
<Project Sdk="Microsoft.NET.Sdk">
52+
<PropertyGroup>
53+
<TargetFramework>net10.0</TargetFramework>
54+
<RestorePackagesPath>packages</RestorePackagesPath>
55+
</PropertyGroup>
56+
<ItemGroup>
57+
<PackageReference Include="EmptyFiles" Version="{version}" />
58+
</ItemGroup>
59+
</Project>
60+
""";
61+
File.WriteAllText(Path.Combine(temp.Path, "Project.csproj"), csproj);
62+
}
63+
64+
static void WriteNugetConfig(TempDirectory temp, string nugetSource)
65+
{
66+
var config = $"""
67+
<?xml version="1.0" encoding="utf-8"?>
68+
<configuration>
69+
<packageSources>
70+
<clear />
71+
<add key="local" value="{nugetSource}" />
72+
</packageSources>
73+
</configuration>
74+
""";
75+
File.WriteAllText(Path.Combine(temp.Path, "nuget.config"), config);
76+
}
77+
78+
static async Task<int> DotnetBuild(TempDirectory temp)
79+
{
80+
var startInfo = new ProcessStartInfo("dotnet", "build --configuration Release --disable-build-servers -nodeReuse:false /p:UseSharedCompilation=false")
81+
{
82+
WorkingDirectory = temp.Path,
83+
RedirectStandardOutput = true,
84+
RedirectStandardError = true,
85+
UseShellExecute = false,
86+
CreateNoWindow = true,
87+
};
88+
89+
using var process = Process.Start(startInfo)!;
90+
var stdout = await process.StandardOutput.ReadToEndAsync();
91+
var stderr = await process.StandardError.ReadToEndAsync();
92+
await process.WaitForExitAsync();
93+
94+
await TestContext.Out.WriteLineAsync("STDOUT:");
95+
await TestContext.Out.WriteLineAsync(stdout);
96+
await TestContext.Out.WriteLineAsync("STDERR:");
97+
await TestContext.Out.WriteLineAsync(stderr);
98+
99+
return process.ExitCode;
100+
}
101+
102+
static (string nugetSource, string packageVersion) FindPackageInfo()
103+
{
104+
var nugetSource = FindNugetSource();
105+
var packageVersion = FindPackageVersion(nugetSource);
106+
return (nugetSource, packageVersion);
107+
}
108+
109+
static string FindNugetSource()
110+
{
111+
var nugetsDir = Path.GetFullPath(Path.Combine(ProjectFiles.SolutionDirectory, "..", "nugets"));
112+
if (Directory.Exists(nugetsDir))
113+
{
114+
return nugetsDir;
115+
}
116+
117+
throw new InvalidOperationException($"Cannot find nugets directory at {nugetsDir}");
118+
}
119+
120+
static string FindPackageVersion(string nugetSource)
121+
{
122+
var nupkg = Directory
123+
.GetFiles(nugetSource, "EmptyFiles.*.nupkg")
124+
.FirstOrDefault(_ => !Path.GetFileName(_).StartsWith("EmptyFiles.Tool")) ??
125+
throw new InvalidOperationException($"Cannot find EmptyFiles nupkg in {nugetSource}");
126+
127+
var fileName = Path.GetFileNameWithoutExtension(nupkg);
128+
return fileName["EmptyFiles.".Length..];
129+
}
130+
}

src/Tests/GlobalUsings.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
global using EmptyFiles;
22
global using NUnit.Framework;
3-
global using System.Collections.Immutable;
3+
global using System.Collections.Immutable;
4+
global using System.Diagnostics;
5+
global using System.Runtime.InteropServices;

src/Tests/SolutionDirectoryFinder.cs

Lines changed: 0 additions & 38 deletions
This file was deleted.

src/Tests/Tests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ public void UseFile()
213213
[Test]
214214
public async Task WriteExtensions()
215215
{
216-
var md = Path.Combine(SolutionDirectoryFinder.Find(), "extensions.include.md");
216+
var md = Path.Combine(ProjectFiles.SolutionDirectory, "extensions.include.md");
217217
File.Delete(md);
218218
await using var writer = File.CreateText(md);
219219
await WriteCategory(writer, "Archive", AllFiles.Archives);

src/Tests/Tests.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
<PackageReference Include="NUnit" />
1313
<PackageReference Include="NUnit3TestAdapter" />
1414
<PackageReference Include="ProjectDefaults" PrivateAssets="all" />
15+
<PackageReference Include="ProjectFiles" />
16+
<PackageReference Include="Verify" />
1517
<ProjectReference Include="..\EmptyFiles\EmptyFiles.csproj" />
1618
<Using Include="NUnit.Framework.Legacy.ClassicAssert" Static="True" />
1719
<Using Include="NUnit.Framework.Assert" Static="True" />

0 commit comments

Comments
 (0)