Search code examples
.net-coremsbuildmsbuild-task

Using Directory.Build.props to copy files from different projects to output dir


I have a solution with a few class libraries and a console project. Each one of the libraries have an Inputs dir next to the .csproj file with a bunch of files.

I'm currently doing this in each one of the library projects, so that the different files end up being copied to the console project output dir. It works as expected:

<ItemGroup>
  <None Update="Inputs\*">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>

Now I'm trying to extract that functionality to Directory.Build.props by doing something like this:

<ItemGroup Condition="$(MSBuildProjectName.StartsWith('AoC')) AND !$(MSBuildProjectName.EndsWith('Test'))" >
  <None Update="$(MSBuildProjectDirectory)\Inputs\*">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>

and I end up with no files being copied to the output directory (neither the library ones or the runner).

Interestingly enough:

  • If I have an Inputs dir in \bin\Debug\net8.0 with files created from the original ItemGroup from a project, those files get deleted if I only keep the 'generic' ItemGroup from Directory.Build.props file.
  • No other files are modified (i.e. deleted).
  • If there's not Inputs dir, it's not even created.

Additionally, this target in Directory.Build.props shows the expected project paths where the Input folders live:

<Target Name="PrintDirs" BeforeTargets="Build" Condition="$(MSBuildProjectName.StartsWith('AoC')) AND !$(MSBuildProjectName.EndsWith('Test'))">
  <Message Importance="High" Text="||---> $(MSBuildProjectDirectory)" />
</Target>
<..>\AoCMultiYear> dotnet build

Versión de MSBuild 17.8.3+195e7f5a3 para .NET
  Determinando los proyectos que se van a restaurar...
  Todos los proyectos están actualizados para la restauración.
  Common -> <..>\AoCMultiYear\src\Common\bin\Debug\net8.0\Common.dll
  AoC2020 -> <..>\AoCMultiYear\src\AoC2020\bin\Debug\net8.0\AoC2020.dll
  AoC2021 -> <..>\AoCMultiYear\src\AoC2021\bin\Debug\net8.0\AoC2021.dll
  ||---> <..>\AoCMultiYear\src\AoC2020
  ||---> <..>\AoCMultiYear\src\AoC2021
  AoC2020.Test -> <..>\AoCMultiYear\tests\AoC2020.Test\bin\Debug\net8.0\AoC2020.Test.dll
  AoC2022 -> <..>\AoCMultiYear\src\AoC2022\bin\Debug\net8.0\AoC2022.dll
  ||---> <..>\AoCMultiYear\src\AoC2022
  Runner -> <..>\AoCMultiYear\src\Runner\bin\Debug\net8.0\Runner.dll

Which piece of the msbuild puzzle am I missing here? :)


Solution

  • Legacy and SDK Projects

    There are two types of MSBuild project files: legacy style and SDK style. Legacy projects are used for the older Windows only .NET Framework. SDK projects were developed for .NET (aka .NET Core) and can support both .NET and .NET Framework.

    MSBuild project files are XML. If the Project element has an SDK attribute, it is an SDK style project.

    e.g.

    <Project Sdk="Microsoft.NET.Sdk">
      ...
    </Project>
    

    SDK style projects move a lot of content out of the actual project file and implement some new capabilities. But with this there are some behavior changes.

    SDK - Default includes and excludes

    Legacy projects generally explicitly specify files that are part of the project. SDK projects has a set of default includes for files. Pertinent to the question, files in the project folder and sub-folders that are not Compile or resource items or of a few other special types are included in the None item collection.

    Directory.Build.props and .targets

    The difference between a Directory.Build.props file and a Directory.Build.targets file is when the file is imported. Both files can contain any valid MSBuild content. (A .props file is not limited to properties and a .targets file is not limited to targets.)

    The Directory.Build.props file is imported very early, before the content of the project file itself and before many standard properties have been defined.

    The Directory.Build.targets file is imported after the content of the project file.

    As a rule of thumb, put content into the Directory.Build.targets file. When there is a need to alter the behavior of the definition of standard properties and items, then use the Directory.Build.props file. As an example, there is a set of properties for altering the default item inclusion. Setting these properties should be done in the Directory.Build.props file. The project file itself (and the Directory.Build.targets file) are too late.

    Factoring Common Functionality

    When factoring common functionality from across a set of project files where the functionality is currently in the project file and is working correctly, move the common code to the Directory.Build.targets file. Essentially that is like moving the code to the end of the project file. You won't break the code because of the special early import of the Directory.Build.props file.

    An Approach for the Inputs folder

    Assuming an SDK style project, create a Directory.Build.targets file with the following content:

    <Project>
      <PropertyGroup>
        <InputsDirectoryName Condition="'$(InputsDirectoryName)' == ''">Inputs</InputsDirectoryName>
        <!-- EnableCopyInputs: default if not set; normalize to always be either 'true' or 'false' -->
        <EnableCopyInputs Condition="'$(EnableCopyInputs)' == ''">true</EnableCopyInputs>
        <EnableCopyInputs Condition="'$(EnableCopyInputs)' != 'true' and '$(EnableCopyInputs)' != 'false'">false</EnableCopyInputs>
      </PropertyGroup>
    
      <ItemGroup Condition="$(EnableCopyInputs) and Exists('$(InputsDirectoryName)')">
        <None Update="$(InputsDirectoryName)\*" >
          <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
        </None>
      </ItemGroup>
    
      <Target Name="DisplayCopyInputsSettings">
        <Message Text="EnableCopyInputs: $(EnableCopyInputs)" />
        <Message Text="InputsDirectoryName: $(InputsDirectoryName)" />
        <PropertyGroup>
          <InputsDirectoryNameExists>false</InputsDirectoryNameExists>
          <InputsDirectoryNameExists Condition="Exists('$(InputsDirectoryName)')">false</InputsDirectoryNameExists>
        </PropertyGroup>
        <Message Text="Directory '$(InputsDirectoryName)' Exists: $(InputsDirectoryNameExists)" />
        <Message Text="@(None->'%(Identity), %(CopyToOutputDirectory)', '%0d%0a')" />
      </Target>
    </Project>
    

    Notes

    • Each project will import the Directory.Build.targets file. The common logic runs in the context of each individual project.
    • The name of the folder is in a property (InputsDirectoryName) and can be overridden. (This could be done on a per project basis.)
    • The copy inputs feature can be enabled or disabled via a property (EnableCopyInputs).
      • This property could be set to false within a project file and/or it could be set based on the project name within the Directory.Build.targets file.
    • The ItemGroup condition requires that the copy feature is enabled and that the directory exists. If a project doesn't have an appropriate existing directory, no changes are made.
    • Because this is an SDK style project, Upgrade is used to modify the metadata of the existing items. They are existing items because the SDK logic will have auto-included them.
      • Upgrade can't be used in the Directory.Build.props file because the SDK's auto inclusion hasn't been performed yet.
    • For testing and diagnostics, the DisplayCopyInputsSettings target reports relevant properties and items. It can be used against a project, e.g. dotnet msbuild -t:DisplayCopyInputsSettings myproject.csproj.