MSBuild, About Import Guards
It is reasonable, for maintainability and for reusability, to parcel MSBuild code into multiple files and then, for a given project, to Import files as needed to support specific functionality.
MSBuild checks for duplicate imports (including 'self' imports). If a duplicate import is detected, MSBuild will show a warning (either MSB4011 'DuplicateImport' or MSB4210 'SelfImport') and block the import. Barring duplicate imports prevents circular references.
However, as the number of files increase and/or the number of maintainers increase, it can become easier to unexpectedly introduce a duplicate import.
Import Guards
Custom MSBuild files can be written with "import guards", which are very much like old school C header file include guards.
Within a given file, define a property that is unique to the file and that will only ever be defined by the given file.
A file named 'common.targets' might start with the following:
<!-- common.targets -->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ImportGuard-CommonTargets>defined</ImportGuard-CommonTargets>
</PropertyGroup>
The name of the property, 'ImportGuard-CommonTargets', is derived from the filename. If there are names that repeat in different folders — for example, if there is a chain of Directory.Build.props or Directory.Build.targets files, then a different convention will be needed to ensure that the property names are unique.
Any import of 'common.targets' should use a Condition to test that the unique property for 'common.targets' is not defined.
<Import Project="common.targets" Condition=" '$(ImportGuard-CommonTargets)' == '' " />
If the property has no value, then the file is imported.
If the property has any value, then the file is already imported and should not be imported again.
With the use of import guards, a file can explicitly import all of its dependencies regardless of imports that may exist in other files. In the following diagram, A.proj can be explicit about import dependencies on both Y.targets and Z.targets.
Without import guards, the import of Z.targets by A.proj is a duplicate import that generates a warning. This is because Y.targets imports Z.targets first. The warning can be resolved by modifying A.proj to remove the import of Z.targets.
With import guards for Y.targets and Z.targets, there is no warning. Further, if Y.targets is changed in the future to remove the import of Z.targets, there is no associated code change to A.proj.
Visualizing the Import Order
The -preprocess command line argument to msbuild.exe (see Switches) will produce output that is all the imported files inlined. The project is not built when this switch is used.
A lighter-weight approach is to create an item group of the imported files and create a target to report the item group.
In each file, add, at the top of the file, an ItemGroup with an include of the current file:
<ItemGroup>
<Diagnostic-FilesList Include="$(MSBuildThisFileFullPath)"/>
</ItemGroup>
A target that reports the ItemGroup would be:
<Target Name="ListFiles">
<Message Text="Project:" />
<Message Text=" $(MSBuildProjectFullPath)" />
<Message Text="Files:" />
<Message Text=" @(Diagnostic-FilesList, '%0d%0a')"/>
</Target>
The Diagnostic-FilesList will be a list of the files being used in the order that the files were imported. This is far less complete then the -preprocess option but, unlike -preprocess, the project is built and other targets can be run along with the ListFiles target.
Example
Following is a full example with five files: A.proj, B.proj, Y.targets, Z.targets, and common.targets.
First is 'common.targets'. The common.targets file defines an import guard. It also adds itself to the Diagnostic-FilesList ItemGroup. common.targets defines the ListFiles target.
<!-- common.targets -->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ImportGuard-CommonTargets>defined</ImportGuard-CommonTargets>
</PropertyGroup>
<!--
Example import:
<Import Project="common.targets" Condition=" '$(ImportGuard-CommonTargets)' == '' " />
-->
<ItemGroup>
<Diagnostic-FilesList Include="$(MSBuildThisFileFullPath)"/>
</ItemGroup>
<Target Name="ListFiles">
<Message Text="Project:" />
<Message Text=" $(MSBuildProjectFullPath)" />
<Message Text="Files:" />
<Message Text=" @(Diagnostic-FilesList, '%0d%0a')"/>
</Target>
</Project>
The 'Z.targets' file defines two targets: DoSomeWork and Customize. Z.targets has an import guard, adds itself to Diagnostic-FilesList, and imports common.targets.
<!-- Z.targets -->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ImportGuard-ZTargets>defined</ImportGuard-ZTargets>
</PropertyGroup>
<ItemGroup>
<Diagnostic-FilesList Include="$(MSBuildThisFileFullPath)"/>
</ItemGroup>
<Target Name="DoSomeWork" DependsOnTargets="Customize">
<Message Text="Working." Condition="'$(Work)' == ''"/>
<Message Text="$(Work)." Condition="'$(Work)' != ''"/>
</Target>
<Target Name="Customize" />
<Import Project="common.targets" Condition=" '$(ImportGuard-CommonTargets)' == '' " />
</Project>
The 'Y.targets' file imports Z.targets and redefines the Customize target. Y.targets has an import guard, adds itself to Diagnostic-FilesList, and imports common.targets.
<!-- Y.targets -->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ImportGuard-YTargets>defined</ImportGuard-YTargets>
</PropertyGroup>
<ItemGroup>
<Diagnostic-FilesList Include="$(MSBuildThisFileFullPath)"/>
</ItemGroup>
<Import Project="Z.targets" Condition=" '$(ImportGuard-ZTargets)' == '' " />
<Target Name="Customize">
<PropertyGroup>
<Work>Writing</Work>
</PropertyGroup>
</Target>
<Import Project="common.targets" Condition=" '$(ImportGuard-CommonTargets)' == '' " />
</Project>
A.proj and B.proj are 'project' files that use the set of .targets files. A.proj and B.proj each adds itself to Diagnostic-FilesList and imports common.targets.
A.proj imports Y.targets which in turn imports Z.targets. A.proj has an import of Z.targets but the condition that tests the import guard will prevent the import.
<!-- A.proj -->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" DefaultTargets="Main">
<ItemGroup>
<Diagnostic-FilesList Include="$(MSBuildThisFileFullPath)"/>
</ItemGroup>
<Import Project="Y.targets" Condition=" '$(ImportGuard-YTargets)' == '' " />
<Import Project="Z.targets" Condition=" '$(ImportGuard-ZTargets)' == '' " />
<Target Name="Main" DependsOnTargets="Preamble;DoSomeWork" />
<!--
Output:
Preamble:
Project A
DoSomeWork:
Writing.
-->
<Target Name="Preamble">
<Message Text="Project A" />
</Target>
<Import Project="common.targets" Condition=" '$(ImportGuard-CommonTargets)' == '' " />
</Project>
B.proj imports Z.targets. The customization that is done in Y.targets is not seen and is not used by B.proj.
<!-- B.proj -->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" DefaultTargets="Main">
<ItemGroup>
<Diagnostic-FilesList Include="$(MSBuildThisFileFullPath)"/>
</ItemGroup>
<Import Project="Z.targets" Condition=" '$(ImportGuard-ZTargets)' == '' " />
<Target Name="Main" DependsOnTargets="Preamble;DoSomeWork" />
<!--
Output:
Preamble:
Project B
DoSomeWork:
Working.
-->
<Target Name="Preamble">
<Message Text="Project B" />
</Target>
<Import Project="common.targets" Condition=" '$(ImportGuard-CommonTargets)' == '' " />
</Project>
Running the command "msbuild A.proj /t:ListFiles" will produce output like the following. (I have omitted the full paths of the files.)
ListFiles:
Project:
A.proj
Files:
A.proj
Y.targets
Z.targets
common.targets
The A.proj, B.proj, Y.targets, and Z.targets files all import common.targets. This means that the ListFiles target can be run against any of the files.
The command "msbuild Y.targets /t:ListFiles" will produce output like the following.
ListFiles:
Project:
Y.targets
Files:
Y.targets
Z.targets
common.targets
Summary
Import guards allow for a set of MSBuild files to be more loosely coupled. Adding or removing an import in a given file doesn't need to ripple out and necessitate changes to other files. Because every MSBuild file is a 'Project', every MSBuild file is invocable. With import guards, files that are not normally an entry point can still be successfully run because the required imports can be present even if in the normal case the imports are not performed. Encapsulating/limiting changes aids maintenance and using a 'library' file as the primary project file aids testing.