Skip to content

Commit 074bba0

Browse files
Migrate Exec task to TaskEnvironment API (#13171)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Jan Provaznik <janprovaznik@microsoft.com>
1 parent 414f0ac commit 074bba0

3 files changed

Lines changed: 249 additions & 7 deletions

File tree

src/Tasks.UnitTests/Exec_Tests.cs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ private Exec PrepareExec(string command)
3737
{
3838
IBuildEngine2 mockEngine = new MockEngine(_output);
3939
Exec exec = new Exec();
40+
exec.TaskEnvironment = TaskEnvironmentHelper.CreateForTest();
4041
exec.BuildEngine = mockEngine;
4142
exec.Command = command;
4243
return exec;
@@ -46,6 +47,7 @@ private ExecWrapper PrepareExecWrapper(string command)
4647
{
4748
IBuildEngine2 mockEngine = new MockEngine(_output);
4849
ExecWrapper exec = new ExecWrapper();
50+
exec.TaskEnvironment = TaskEnvironmentHelper.CreateForTest();
4951
exec.BuildEngine = mockEngine;
5052
exec.Command = command;
5153
return exec;
@@ -905,6 +907,7 @@ public void ValidateParametersNoCommand()
905907
public void SetEnvironmentVariableParameter()
906908
{
907909
Exec exec = new Exec();
910+
exec.TaskEnvironment = TaskEnvironmentHelper.CreateForTest();
908911
exec.BuildEngine = new MockEngine();
909912
exec.Command = NativeMethodsShared.IsWindows ? "echo [%MYENVVAR%]" : "echo [$myenvvar]";
910913
exec.EnvironmentVariables = new[] { "myenvvar=myvalue" };
@@ -1069,6 +1072,166 @@ public void ConsoleOutputDoesNotTrimLeadingWhitespace()
10691072
exec.ConsoleOutput[0].ItemSpec.ShouldBe(lineWithLeadingWhitespace);
10701073
}
10711074
}
1075+
1076+
/// <summary>
1077+
/// Runs an Exec task that lists directory contents and asserts expected/unexpected files in the output.
1078+
/// </summary>
1079+
/// <param name="taskEnvironment">The TaskEnvironment to configure on the Exec task.</param>
1080+
/// <param name="workingDirectory">The WorkingDirectory to set, or null to use the default.</param>
1081+
/// <param name="expectedFile">A filename that must appear in the output.</param>
1082+
/// <param name="notExpectedFile">A filename that must NOT appear in the output, or null to skip.</param>
1083+
private void ExecuteListCommandInDirectory(
1084+
TaskEnvironment taskEnvironment,
1085+
string workingDirectory,
1086+
string expectedFile,
1087+
string notExpectedFile = null)
1088+
{
1089+
Exec exec = new Exec();
1090+
exec.TaskEnvironment = taskEnvironment;
1091+
exec.BuildEngine = new MockEngine(_output);
1092+
exec.Command = NativeMethodsShared.IsWindows ? "dir /b" : "ls";
1093+
exec.ConsoleToMSBuild = true;
1094+
1095+
if (workingDirectory != null)
1096+
{
1097+
exec.WorkingDirectory = workingDirectory;
1098+
}
1099+
1100+
bool result = exec.Execute();
1101+
1102+
result.ShouldBeTrue();
1103+
((MockEngine)exec.BuildEngine).AssertLogContains(expectedFile);
1104+
if (notExpectedFile != null)
1105+
{
1106+
((MockEngine)exec.BuildEngine).AssertLogDoesntContain(notExpectedFile);
1107+
}
1108+
}
1109+
1110+
/// <summary>
1111+
/// Verify that Exec resolves relative WorkingDirectory via TaskEnvironment.GetAbsolutePath in multiprocess mode.
1112+
/// </summary>
1113+
[Fact]
1114+
public void ExecResolvesRelativeWorkingDirectoryWithMultiProcessDriver()
1115+
{
1116+
using (var testEnv = TestEnvironment.Create(_output))
1117+
{
1118+
var projectDir = testEnv.CreateFolder();
1119+
var subDir = Directory.CreateDirectory(Path.Combine(projectDir.Path, "subdir"));
1120+
File.WriteAllText(Path.Combine(subDir.FullName, "testfile.txt"), "test content");
1121+
1122+
var differentDir = testEnv.CreateFolder();
1123+
var decoySubDir = Directory.CreateDirectory(Path.Combine(differentDir.Path, "subdir"));
1124+
File.WriteAllText(Path.Combine(decoySubDir.FullName, "decoyfile.txt"), "decoy content");
1125+
1126+
string originalDirectory = Directory.GetCurrentDirectory();
1127+
try
1128+
{
1129+
Directory.SetCurrentDirectory(projectDir.Path);
1130+
1131+
ExecuteListCommandInDirectory(
1132+
TaskEnvironmentHelper.CreateForTest(),
1133+
workingDirectory: "subdir",
1134+
expectedFile: "testfile.txt",
1135+
notExpectedFile: "decoyfile.txt");
1136+
}
1137+
finally
1138+
{
1139+
Directory.SetCurrentDirectory(originalDirectory);
1140+
}
1141+
}
1142+
}
1143+
1144+
/// <summary>
1145+
/// Verify that Exec uses TaskEnvironment.ProjectDirectory when WorkingDirectory is not specified.
1146+
/// Uses MultiThreadedTaskEnvironmentDriver so process CWD differs from project directory.
1147+
/// </summary>
1148+
[Fact]
1149+
public void ExecUsesProjectDirectoryAsDefaultWorkingDirectory()
1150+
{
1151+
using (var testEnv = TestEnvironment.Create(_output))
1152+
{
1153+
var projectDir = testEnv.CreateFolder();
1154+
File.WriteAllText(Path.Combine(projectDir.Path, "projectfile.txt"), "project content");
1155+
1156+
var differentCwd = testEnv.CreateFolder();
1157+
File.WriteAllText(Path.Combine(differentCwd.Path, "decoyfile.txt"), "decoy content");
1158+
1159+
string originalDirectory = Directory.GetCurrentDirectory();
1160+
TaskEnvironment taskEnvironment = null;
1161+
try
1162+
{
1163+
Directory.SetCurrentDirectory(differentCwd.Path);
1164+
1165+
taskEnvironment = TaskEnvironmentHelper.CreateMultithreadedForTest(projectDir.Path);
1166+
ExecuteListCommandInDirectory(
1167+
taskEnvironment,
1168+
workingDirectory: null,
1169+
expectedFile: "projectfile.txt",
1170+
notExpectedFile: "decoyfile.txt");
1171+
}
1172+
finally
1173+
{
1174+
taskEnvironment?.Dispose();
1175+
Directory.SetCurrentDirectory(originalDirectory);
1176+
}
1177+
}
1178+
}
1179+
1180+
/// <summary>
1181+
/// Verify that Exec correctly handles absolute WorkingDirectory paths.
1182+
/// </summary>
1183+
[Fact]
1184+
public void ExecHandlesAbsoluteWorkingDirectory()
1185+
{
1186+
using (var testEnv = TestEnvironment.Create(_output))
1187+
{
1188+
var workDir = testEnv.CreateFolder();
1189+
File.WriteAllText(Path.Combine(workDir.Path, "absolutedir.txt"), "absolute content");
1190+
1191+
ExecuteListCommandInDirectory(
1192+
TaskEnvironmentHelper.CreateForTest(),
1193+
workingDirectory: workDir.Path,
1194+
expectedFile: "absolutedir.txt");
1195+
}
1196+
}
1197+
1198+
/// <summary>
1199+
/// Verify that Exec resolves relative WorkingDirectory relative to TaskEnvironment.ProjectDirectory,
1200+
/// not the process current directory. Uses MultiThreadedTaskEnvironmentDriver to simulate
1201+
/// multithreaded mode where process CWD differs from project directory.
1202+
/// </summary>
1203+
[Fact]
1204+
public void ExecResolvesRelativeWorkingDirectoryRelativeToProjectDirectory()
1205+
{
1206+
using (var testEnv = TestEnvironment.Create(_output))
1207+
{
1208+
var projectDir = testEnv.CreateFolder();
1209+
var subDir = Directory.CreateDirectory(Path.Combine(projectDir.Path, "builddir"));
1210+
File.WriteAllText(Path.Combine(subDir.FullName, "multithreaded.txt"), "multithreaded content");
1211+
1212+
var differentCwd = testEnv.CreateFolder();
1213+
File.WriteAllText(Path.Combine(differentCwd.Path, "decoyfile.txt"), "decoy content");
1214+
1215+
string originalDirectory = Directory.GetCurrentDirectory();
1216+
TaskEnvironment taskEnvironment = null;
1217+
try
1218+
{
1219+
Directory.SetCurrentDirectory(differentCwd.Path);
1220+
1221+
taskEnvironment = TaskEnvironmentHelper.CreateMultithreadedForTest(projectDir.Path);
1222+
ExecuteListCommandInDirectory(
1223+
taskEnvironment,
1224+
workingDirectory: "builddir",
1225+
expectedFile: "multithreaded.txt",
1226+
notExpectedFile: "decoyfile.txt");
1227+
}
1228+
finally
1229+
{
1230+
taskEnvironment?.Dispose();
1231+
Directory.SetCurrentDirectory(originalDirectory);
1232+
}
1233+
}
1234+
}
10721235
}
10731236

10741237
internal sealed class ExecWrapper : Exec

src/Tasks/Exec.cs

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Diagnostics;
67
using System.Diagnostics.CodeAnalysis;
78
using System.IO;
89
using System.Text;
@@ -21,7 +22,8 @@ namespace Microsoft.Build.Tasks
2122
/// for it to complete, and then returns True if the process completed successfully, and False if an error occurred.
2223
/// </summary>
2324
// UNDONE: ToolTask has a "UseCommandProcessor" flag that duplicates much of the code in this class. Remove the duplication.
24-
public class Exec : ToolTaskExtension
25+
[MSBuildMultiThreadableTask]
26+
public class Exec : ToolTaskExtension, IMultiThreadableTask
2527
{
2628
#region Constructors
2729

@@ -46,7 +48,7 @@ public Exec()
4648

4749
// Are the encodings for StdErr and StdOut streams valid
4850
private bool _encodingParametersValid = true;
49-
private string _workingDirectory;
51+
private AbsolutePath _workingDirectory;
5052
private ITaskItem[] _outputs;
5153
internal bool workingDirectoryIsUNC; // internal for unit testing
5254
private string _batchFile;
@@ -82,6 +84,9 @@ public string Command
8284

8385
public bool IgnoreExitCode { get; set; }
8486

87+
/// <inheritdoc />
88+
public TaskEnvironment TaskEnvironment { get; set; }
89+
8590
/// <summary>
8691
/// Enable the pipe of the standard out to an item (StandardOutput).
8792
/// </summary>
@@ -458,10 +463,12 @@ protected override bool ValidateParameters()
458463
}
459464

460465
// determine what the working directory for the exec command is going to be -- if the user specified a working
461-
// directory use that, otherwise it's the current directory
466+
// directory use that, otherwise default to the project directory (TaskEnvironment.ProjectDirectory). Using the
467+
// project directory instead of the process current directory is important for correctness in multithreaded (/mt)
468+
// builds, where the process working directory may not match the project being built.
462469
_workingDirectory = !string.IsNullOrEmpty(WorkingDirectory)
463-
? WorkingDirectory
464-
: Directory.GetCurrentDirectory();
470+
? TaskEnvironment.GetAbsolutePath(WorkingDirectory)
471+
: TaskEnvironment.ProjectDirectory;
465472

466473
// check if the working directory we're going to use for the exec command is a UNC path
467474
workingDirectoryIsUNC = FileUtilitiesRegex.StartsWithUncPattern(_workingDirectory);
@@ -470,7 +477,7 @@ protected override bool ValidateParameters()
470477
// will not be able to auto-map to the UNC path
471478
if (workingDirectoryIsUNC && NativeMethods.AllDrivesMapped())
472479
{
473-
Log.LogErrorWithCodeFromResources("Exec.AllDriveLettersMappedError", _workingDirectory);
480+
Log.LogErrorWithCodeFromResources("Exec.AllDriveLettersMappedError", _workingDirectory.OriginalValue);
474481
return false;
475482
}
476483

@@ -533,7 +540,7 @@ protected override string GetWorkingDirectory()
533540
// So verify it's valid here.
534541
if (!FileSystems.Default.DirectoryExists(_workingDirectory))
535542
{
536-
throw new DirectoryNotFoundException(ResourceUtilities.FormatResourceStringStripCodeAndKeyword("Exec.InvalidWorkingDirectory", _workingDirectory));
543+
throw new DirectoryNotFoundException(ResourceUtilities.FormatResourceStringStripCodeAndKeyword("Exec.InvalidWorkingDirectory", _workingDirectory.OriginalValue));
537544
}
538545

539546
if (workingDirectoryIsUNC)
@@ -560,6 +567,59 @@ internal string GetWorkingDirectoryAccessor()
560567
return GetWorkingDirectory();
561568
}
562569

570+
/// <summary>
571+
/// Gets the ProcessStartInfo for the spawned process, with environment variables from TaskEnvironment.
572+
/// In multithreaded mode, TaskEnvironment contains the virtualized environment for this project,
573+
/// which must be passed to the spawned process since it won't inherit it from the (shared) process environment.
574+
/// </summary>
575+
protected override ProcessStartInfo GetProcessStartInfo(
576+
string pathToTool,
577+
string commandLineCommands,
578+
string responseFileSwitch)
579+
{
580+
// Get the base ProcessStartInfo with all ToolTask settings (command line, redirections, encodings, etc.)
581+
// This also applies EnvironmentVariables overrides from the task property.
582+
ProcessStartInfo startInfo = base.GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch);
583+
584+
// Replace the inherited process environment with the virtualized one from TaskEnvironment.
585+
// TaskEnvironment.GetProcessStartInfo() already configures env vars and working directory correctly
586+
// for both multithreaded (virtualized) and multi-process (inherited) modes.
587+
ProcessStartInfo taskEnvStartInfo = TaskEnvironment.GetProcessStartInfo();
588+
startInfo.Environment.Clear();
589+
foreach (var kvp in taskEnvStartInfo.Environment)
590+
{
591+
startInfo.Environment[kvp.Key] = kvp.Value;
592+
}
593+
594+
// Re-apply obsolete EnvironmentOverride and EnvironmentVariables property overrides —
595+
// they should take precedence over TaskEnvironment. The base class already applied these,
596+
// but we cleared the environment above, so we need to re-apply them.
597+
#pragma warning disable 0618 // obsolete
598+
Dictionary<string, string> envOverrides = EnvironmentOverride;
599+
if (envOverrides != null)
600+
{
601+
foreach (KeyValuePair<string, string> entry in envOverrides)
602+
{
603+
startInfo.Environment[entry.Key] = entry.Value;
604+
}
605+
}
606+
#pragma warning restore 0618
607+
608+
if (EnvironmentVariables != null)
609+
{
610+
foreach (string entry in EnvironmentVariables)
611+
{
612+
string[] nameValuePair = entry.Split(['='], 2);
613+
if (nameValuePair.Length == 2 && nameValuePair[0].Length > 0)
614+
{
615+
startInfo.Environment[nameValuePair[0]] = nameValuePair[1];
616+
}
617+
}
618+
}
619+
620+
return startInfo;
621+
}
622+
563623
/// <summary>
564624
/// Adds the arguments for cmd.exe
565625
/// </summary>

src/UnitTests.Shared/TaskEnvironmentHelper.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,24 @@ public static TaskEnvironment CreateForTest()
2020
{
2121
return new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);
2222
}
23+
24+
/// <summary>
25+
/// Creates a TaskEnvironment backed by the multi-threaded driver which virtualizes
26+
/// environment variables and current directory. This allows testing of multithreaded mode
27+
/// behavior where each project has its own isolated environment.
28+
/// </summary>
29+
/// <param name="projectDirectory">The project directory to use for the task environment.</param>
30+
/// <returns>A TaskEnvironment suitable for testing multithreaded mode scenarios.</returns>
31+
/// <remarks>
32+
/// The caller is responsible for disposing the TaskEnvironment via TaskEnvironment.Dispose(),
33+
/// which will clean up the underlying driver's thread-local state.
34+
/// </remarks>
35+
// CA2000 is suppressed because the driver is owned by the TaskEnvironment and disposed via TaskEnvironment.Dispose()
36+
#pragma warning disable CA2000
37+
public static TaskEnvironment CreateMultithreadedForTest(string projectDirectory)
38+
{
39+
return new TaskEnvironment(new MultiThreadedTaskEnvironmentDriver(projectDirectory));
40+
}
41+
#pragma warning restore CA2000
2342
}
2443
}

0 commit comments

Comments
 (0)