diff --git a/src/Tasks.UnitTests/CreateItem_Tests.cs b/src/Tasks.UnitTests/CreateItem_Tests.cs
index d75da37b32b..a16a1f94745 100644
--- a/src/Tasks.UnitTests/CreateItem_Tests.cs
+++ b/src/Tasks.UnitTests/CreateItem_Tests.cs
@@ -444,5 +444,218 @@ public void LogUnixWarningUponItemCreationWithDriveEnumeration(string content, s
Helpers.ExpectedBuildResult.SucceedWithWarning,
_testOutput);
}
+
+ ///
+ /// Relative wildcard Include resolves against TaskEnvironment.ProjectDirectory, not cwd.
+ ///
+ [Fact]
+ public void WildcardInclude_ResolvesAgainstProjectDirectory()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_testOutput);
+
+ // Create a project directory with a file.
+ TransientTestFolder projectDir = env.CreateFolder(createFolder: true);
+ env.CreateFile(projectDir, "alpha.txt", "alpha");
+
+ // Create a separate directory that is NOT the project directory and put a different file there.
+ TransientTestFolder otherDir = env.CreateFolder(createFolder: true);
+ env.CreateFile(otherDir, "beta.txt", "beta");
+
+ // Set cwd to otherDir so that if the task used cwd, it would find beta.txt instead.
+ env.SetCurrentDirectory(otherDir.Path);
+
+ CreateItem t = new CreateItem
+ {
+ BuildEngine = new MockEngine(_testOutput),
+ Include = new ITaskItem[] { new TaskItem("*.txt") },
+ TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir.Path),
+ };
+
+ t.Execute().ShouldBeTrue();
+ t.Include.Length.ShouldBe(1);
+ t.Include[0].ItemSpec.ShouldBe("alpha.txt");
+ }
+
+ ///
+ /// Relative wildcard Exclude resolves against TaskEnvironment.ProjectDirectory, not cwd.
+ /// The *.log exclude should match SampleFile.log in the project directory and filter it out.
+ ///
+ [Fact]
+ public void WildcardExclude_ResolvesAgainstProjectDirectory()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_testOutput);
+
+ TransientTestFolder projectDir = env.CreateFolder(createFolder: true);
+ env.CreateFile(projectDir, "SampleFile.log", "SampleFile");
+
+ // cwd points elsewhere — Exclude must still resolve *.log against projectDir.
+ TransientTestFolder otherDir = env.CreateFolder(createFolder: true);
+ env.SetCurrentDirectory(otherDir.Path);
+
+ CreateItem t = new CreateItem
+ {
+ BuildEngine = new MockEngine(_testOutput),
+ Include = new ITaskItem[] { new TaskItem("*.log") },
+ Exclude = new ITaskItem[] { new TaskItem("*.log") },
+ TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir.Path),
+ };
+
+ t.Execute().ShouldBeTrue();
+ t.Include.ShouldBeEmpty();
+ }
+
+ ///
+ /// RecursiveDir metadata is correctly set when wildcard expansion uses a custom ProjectDirectory.
+ ///
+ [Fact]
+ public void RecursiveDir_WithCustomProjectDirectory()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_testOutput);
+
+ TransientTestFolder projectDir = env.CreateFolder(createFolder: true);
+ string sampleSubdirectory = Path.Combine(projectDir.Path, "SampleSubdirectory");
+ Directory.CreateDirectory(sampleSubdirectory);
+ File.WriteAllText(Path.Combine(sampleSubdirectory, "SampleFile.txt"), "content");
+
+ // Set cwd somewhere else to prove ProjectDirectory is used.
+ TransientTestFolder otherDir = env.CreateFolder(createFolder: true);
+ env.SetCurrentDirectory(otherDir.Path);
+
+ CreateItem t = new CreateItem
+ {
+ BuildEngine = new MockEngine(_testOutput),
+ Include = new ITaskItem[] { new TaskItem($"**{Path.DirectorySeparatorChar}*.txt") },
+ TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir.Path),
+ };
+
+ t.Execute().ShouldBeTrue();
+ t.Include.Length.ShouldBe(1);
+
+ string recursiveDir = t.Include[0].GetMetadata("RecursiveDir");
+ recursiveDir.ShouldBe("SampleSubdirectory" + Path.DirectorySeparatorChar);
+ }
+
+ ///
+ /// Absolute wildcard patterns are unaffected by ProjectDirectory — the same files are returned
+ /// regardless of what ProjectDirectory is set to.
+ ///
+ [Fact]
+ public void AbsoluteWildcard_IgnoresProjectDirectory()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_testOutput);
+
+ TransientTestFolder targetDir = env.CreateFolder(createFolder: true);
+ env.CreateFile(targetDir, "SampleFile.txt", "content");
+
+ // Use an unrelated ProjectDirectory.
+ TransientTestFolder unrelatedDir = env.CreateFolder(createFolder: true);
+
+ string absolutePattern = Path.Combine(targetDir.Path, "*.txt");
+ CreateItem t = new CreateItem
+ {
+ BuildEngine = new MockEngine(_testOutput),
+ Include = new ITaskItem[] { new TaskItem(absolutePattern) },
+ TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(unrelatedDir.Path),
+ };
+
+ t.Execute().ShouldBeTrue();
+ t.Include.Length.ShouldBe(1);
+ // Absolute pattern returns absolute path.
+ Path.GetFileName(t.Include[0].ItemSpec).ShouldBe("SampleFile.txt");
+ }
+
+ ///
+ /// When a relative wildcard ItemSpec has a multi-segment fixed directory containing a tilde
+ /// (e.g. "SubDir/ALONGD~1/*.txt"), GetLongPathName inside SplitFileSpec resolves the tilde
+ /// segment against CWD instead of TaskEnvironment.ProjectDirectory. If CWD contains a
+ /// directory whose 8.3 short name matches but whose long name differs, the resolution
+ /// produces the wrong long name, the absolutized path doesn't exist in the project directory,
+ /// and GetFiles returns no results.
+ ///
+ /// This test sets CWD to a directory whose "SubDir" contains a long-named child that maps
+ /// to the same 8.3 short name as the project directory's child. It then verifies that
+ /// CreateItem still finds the file in the project directory — which currently fails.
+ ///
+ [WindowsOnlyFact(additionalMessage: "8.3 short names are Windows-only.")]
+ public void WildcardWithShortName_ResolvesAgainstProjectDirectory_NotCwd()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_testOutput);
+
+ // Project directory: SubDir/ALongDirectoryNameHere/test.txt
+ TransientTestFolder projectDir = env.CreateFolder(createFolder: true);
+ string projectSubDir = Path.Combine(projectDir.Path, "SubDir");
+ string projectChild = Path.Combine(projectSubDir, "ALongDirectoryNameHere");
+ Directory.CreateDirectory(projectChild);
+ File.WriteAllText(Path.Combine(projectChild, "test.txt"), "project-content");
+
+ // Determine the 8.3 short name for the child directory.
+ string shortChildPath = NativeMethodsShared.GetShortFilePath(projectChild);
+ string shortChildName = Path.GetFileName(shortChildPath);
+
+ // If 8.3 names are disabled on this volume, the short name won't contain '~' — skip.
+ if (!shortChildName.Contains("~"))
+ {
+ _testOutput.WriteLine($"Skipping: 8.3 names appear disabled (short name was '{shortChildName}').");
+ return;
+ }
+
+ // CWD directory: SubDir/ALongDirectoryNameThere/decoy.txt
+ // "ALongDirectoryNameThere" shares the first 6 characters with "ALongDirectoryNameHere",
+ // so in its own parent it also gets short name ALONGD~1.
+ TransientTestFolder cwdDir = env.CreateFolder(createFolder: true);
+ string cwdSubDir = Path.Combine(cwdDir.Path, "SubDir");
+ string cwdChild = Path.Combine(cwdSubDir, "ALongDirectoryNameThere");
+ Directory.CreateDirectory(cwdChild);
+ File.WriteAllText(Path.Combine(cwdChild, "decoy.txt"), "decoy-content");
+
+ // Verify the decoy also gets the same short name.
+ string shortDecoyPath = NativeMethodsShared.GetShortFilePath(cwdChild);
+ string shortDecoyName = Path.GetFileName(shortDecoyPath);
+ shortDecoyName.ShouldBe(shortChildName, customMessage:
+ "Both directories should have the same 8.3 short name since they share the first 6 characters.",
+ options: StringCompareShould.IgnoreCase);
+
+ // Set CWD to cwdDir — GetLongPathName will resolve the short name against this directory.
+ env.SetCurrentDirectory(cwdDir.Path);
+
+ // ItemSpec uses the 8.3 short name in a multi-segment relative path.
+ // SplitFileSpec splits into fixedDir = "SubDir/ALONGD~1/", GetLongPathName resolves
+ // "ALONGD~1" under "SubDir" in CWD → gets "ALongDirectoryNameThere" (WRONG).
+ // GetFileSearchData then absolutizes to "{projectDir}/SubDir/ALongDirectoryNameThere/"
+ // which doesn't exist → returns empty.
+ string itemSpec = Path.Combine("SubDir", shortChildName, "*.txt");
+ CreateItem t = new CreateItem
+ {
+ BuildEngine = new MockEngine(_testOutput),
+ Include = new ITaskItem[] { new TaskItem(itemSpec) },
+ TaskEnvironment = TaskEnvironment.CreateWithProjectDirectoryAndEnvironment(projectDir.Path),
+ };
+
+ t.Execute().ShouldBeTrue();
+ t.Include.Length.ShouldBe(1, $"Expected 1 file from project directory, but got {t.Include.Length}. " +
+ "GetLongPathName likely resolved the short name against CWD instead of ProjectDirectory.");
+ Path.GetFileName(t.Include[0].ItemSpec).ShouldBe("test.txt");
+ }
+
+ ///
+ /// Pre-expanded literal items (no wildcards) produce identical output regardless of
+ /// ProjectDirectory, Exclude, and AdditionalMetadata settings.
+ ///
+ [Fact]
+ public void LiteralItems_UnaffectedByProjectDirectory()
+ {
+ CreateItem t = new CreateItem
+ {
+ BuildEngine = new MockEngine(_testOutput),
+ Include = new ITaskItem[] { new TaskItem("A.txt"), new TaskItem("B.txt") },
+ Exclude = new ITaskItem[] { new TaskItem("B.txt") },
+ AdditionalMetadata = new string[] { "Tag=Value" },
+ };
+
+ t.Execute().ShouldBeTrue();
+ t.Include.Length.ShouldBe(1);
+ t.Include[0].ItemSpec.ShouldBe("A.txt");
+ t.Include[0].GetMetadata("Tag").ShouldBe("Value");
+ }
}
}
diff --git a/src/Tasks/CreateItem.cs b/src/Tasks/CreateItem.cs
index cfb61b0919f..2107e091e5b 100644
--- a/src/Tasks/CreateItem.cs
+++ b/src/Tasks/CreateItem.cs
@@ -14,10 +14,14 @@ namespace Microsoft.Build.Tasks
///
/// Forward a list of items from input to output. This allows dynamic item lists.
///
- public class CreateItem : TaskExtension
+ [MSBuildMultiThreadableTask]
+ public class CreateItem : TaskExtension, IMultiThreadableTask
{
#region Properties
+ ///
+ public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;
+
[Output]
public ITaskItem[] Include { get; set; }
@@ -155,7 +159,12 @@ private List CreateOutputItems(Dictionary metadataTab
{
if (FileMatcher.HasWildcards(i.ItemSpec))
{
- FileMatcher.Default.GetFileSpecInfo(i.ItemSpec, out string directoryPart, out string wildcardPart, out string filenamePart, out bool needsRecursion, out bool isLegalFileSpec);
+ // Absolutize against TaskEnvironment.ProjectDirectory so that any short-name (e.g. "ALONGD~1")
+ // segments inside the fixed directory portion are resolved against the project directory
+ // rather than the process current directory (which FileMatcher.GetLongPathName would otherwise use).
+ string absoluteItemSpec = TaskEnvironment.GetAbsolutePath(i.ItemSpec);
+
+ FileMatcher.Default.GetFileSpecInfo(absoluteItemSpec, out string directoryPart, out string wildcardPart, out string filenamePart, out bool needsRecursion, out bool isLegalFileSpec);
bool logDriveEnumeratingWildcard = FileMatcher.IsDriveEnumeratingWildcardPattern(directoryPart, wildcardPart);
if (logDriveEnumeratingWildcard)
{
@@ -178,7 +187,7 @@ private List CreateOutputItems(Dictionary metadataTab
}
else if (isLegalFileSpec)
{
- (files, _, _, string? globFailure) = FileMatcher.Default.GetFiles(null /* use current directory */, i.ItemSpec);
+ (files, _, _, string? globFailure) = FileMatcher.Default.GetFiles(TaskEnvironment.ProjectDirectory, absoluteItemSpec);
if (globFailure != null)
{
Log.LogMessage(MessageImportance.Low, globFailure);
@@ -189,7 +198,7 @@ private List CreateOutputItems(Dictionary metadataTab
TaskItem newItem = new TaskItem(i) { ItemSpec = file };
// Compute the RecursiveDir portion.
- FileMatcher.Result match = FileMatcher.Default.FileMatch(i.ItemSpec, file);
+ FileMatcher.Result match = FileMatcher.Default.FileMatch(absoluteItemSpec, file);
if (match.isLegalFileSpec && match.isMatch)
{
if (!string.IsNullOrEmpty(match.wildcardDirectoryPart))