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
7 changes: 5 additions & 2 deletions buildTransitive/EmptyFileTargets.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
How it works:

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

This will only copy files when:

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

Benefits:

* Dramatically reduces IO on incremental builds
* Works with dotnet clean (removes marker file)
* Recovers when the output directory is cleaned without cleaning the intermediate directory
* Still respects individual file changes via SkipUnchangedFiles
8 changes: 7 additions & 1 deletion buildTransitive/EmptyFiles.targets
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
<EmptyFilesSourcePath>$(MSBuildThisFileDirectory)..\files</EmptyFilesSourcePath>
</PropertyGroup>

<Target Name="DeleteEmptyFilesMarkerIfTargetMissing"
BeforeTargets="CopyEmptyFilesIncremental"
Condition="$(DesignTimeBuild) != true AND !Exists('$(TargetDir)EmptyFiles')">
<Delete Files="$(IntermediateOutputPath)EmptyFiles.copied" />
</Target>

<Target Name="CopyEmptyFilesIncremental"
AfterTargets="Build"
Condition="$(DesignTimeBuild) != true"
Expand All @@ -29,4 +35,4 @@

<Touch Files="$(EmptyFilesMarker)" AlwaysCreate="true" />
</Target>
</Project>
</Project>
2 changes: 2 additions & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
<PackageVersion Include="NUnit3TestAdapter" Version="5.2.0" Pinned="true" />
<PackageVersion Include="ProjectDefaults" Version="1.0.171" />
<PackageVersion Include="Polyfill" Version="9.8.1" />
<PackageVersion Include="ProjectFiles" Version="0.5.0" />
<PackageVersion Include="System.Memory" Version="4.6.3" />
<PackageVersion Include="Microsoft.Sbom.Targets" Version="4.1.5" />
<!-- Keep at 8 to support old frameworks -->
<PackageVersion Include="System.Collections.Immutable" Version="8.0.0" Pinned="true" />
<PackageVersion Include="System.ValueTuple" Version="4.5.0" Pinned="true" />
<PackageVersion Include="Verify" Version="31.11.0" />
</ItemGroup>
</Project>
130 changes: 130 additions & 0 deletions src/Tests/BuildTargetsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
public class BuildTargetsTests
{
[Test]
public async Task BuildCopiesEmptyFiles()
{
using var temp = new TempDirectory();

var (nugetSource, packageVersion) = FindPackageInfo();
WriteCsproj(temp, packageVersion);
WriteNugetConfig(temp, nugetSource);

var exitCode = await DotnetBuild(temp);
That(exitCode, Is.EqualTo(0));

var emptyFilesDir = Path.Combine(temp.Path, "bin", "Release", "net10.0", "EmptyFiles");
That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after build");
That(Directory.GetFiles(emptyFilesDir, "*", SearchOption.AllDirectories), Is.Not.Empty, "EmptyFiles directory should contain files");
}

[Test]
public async Task RecoverFromDeletedEmptyFilesDirectory()
{
using var temp = new TempDirectory();

var (nugetSource, packageVersion) = FindPackageInfo();
WriteCsproj(temp, packageVersion);
WriteNugetConfig(temp, nugetSource);

// First build
var exitCode = await DotnetBuild(temp);
That(exitCode, Is.EqualTo(0));

var emptyFilesDir = Path.Combine(temp.Path, "bin", "Release", "net10.0", "EmptyFiles");
That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after first build");

// Delete EmptyFiles directory from output (leave obj/ intact so marker file survives)
Directory.Delete(emptyFilesDir, true);
That(Directory.Exists(emptyFilesDir), Is.False);

// Second build — should recover
exitCode = await DotnetBuild(temp);
That(exitCode, Is.EqualTo(0));

That(Directory.Exists(emptyFilesDir), Is.True, "EmptyFiles directory should exist after second build");
That(Directory.GetFiles(emptyFilesDir, "*", SearchOption.AllDirectories), Is.Not.Empty, "EmptyFiles directory should contain files after recovery");
}

static void WriteCsproj(TempDirectory temp, string version)
{
var csproj = $"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RestorePackagesPath>packages</RestorePackagesPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EmptyFiles" Version="{version}" />
</ItemGroup>
</Project>
""";
File.WriteAllText(Path.Combine(temp.Path, "Project.csproj"), csproj);
}

static void WriteNugetConfig(TempDirectory temp, string nugetSource)
{
var config = $"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="local" value="{nugetSource}" />
</packageSources>
</configuration>
""";
File.WriteAllText(Path.Combine(temp.Path, "nuget.config"), config);
}

static async Task<int> DotnetBuild(TempDirectory temp)
{
var startInfo = new ProcessStartInfo("dotnet", "build --configuration Release --disable-build-servers -nodeReuse:false /p:UseSharedCompilation=false")
{
WorkingDirectory = temp.Path,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};

using var process = Process.Start(startInfo)!;
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();

await TestContext.Out.WriteLineAsync("STDOUT:");
await TestContext.Out.WriteLineAsync(stdout);
await TestContext.Out.WriteLineAsync("STDERR:");
await TestContext.Out.WriteLineAsync(stderr);

return process.ExitCode;
}

static (string nugetSource, string packageVersion) FindPackageInfo()
{
var nugetSource = FindNugetSource();
var packageVersion = FindPackageVersion(nugetSource);
return (nugetSource, packageVersion);
}

static string FindNugetSource()
{
var nugetsDir = Path.GetFullPath(Path.Combine(ProjectFiles.SolutionDirectory, "..", "nugets"));
if (Directory.Exists(nugetsDir))
{
return nugetsDir;
}

throw new InvalidOperationException($"Cannot find nugets directory at {nugetsDir}");
}

static string FindPackageVersion(string nugetSource)
{
var nupkg = Directory
.GetFiles(nugetSource, "EmptyFiles.*.nupkg")
.FirstOrDefault(_ => !Path.GetFileName(_).StartsWith("EmptyFiles.Tool")) ??
throw new InvalidOperationException($"Cannot find EmptyFiles nupkg in {nugetSource}");

var fileName = Path.GetFileNameWithoutExtension(nupkg);
return fileName["EmptyFiles.".Length..];
}
}
4 changes: 3 additions & 1 deletion src/Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
global using EmptyFiles;
global using NUnit.Framework;
global using System.Collections.Immutable;
global using System.Collections.Immutable;
global using System.Diagnostics;
global using System.Runtime.InteropServices;
38 changes: 0 additions & 38 deletions src/Tests/SolutionDirectoryFinder.cs

This file was deleted.

2 changes: 1 addition & 1 deletion src/Tests/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ public void UseFile()
[Test]
public async Task WriteExtensions()
{
var md = Path.Combine(SolutionDirectoryFinder.Find(), "extensions.include.md");
var md = Path.Combine(ProjectFiles.SolutionDirectory, "extensions.include.md");
File.Delete(md);
await using var writer = File.CreateText(md);
await WriteCategory(writer, "Archive", AllFiles.Archives);
Expand Down
2 changes: 2 additions & 0 deletions src/Tests/Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="ProjectDefaults" PrivateAssets="all" />
<PackageReference Include="ProjectFiles" />
<PackageReference Include="Verify" />
<ProjectReference Include="..\EmptyFiles\EmptyFiles.csproj" />
<Using Include="NUnit.Framework.Legacy.ClassicAssert" Static="True" />
<Using Include="NUnit.Framework.Assert" Static="True" />
Expand Down