diff --git a/documentation/wiki/ChangeWaves.md b/documentation/wiki/ChangeWaves.md
index a6fd5b23f0f..0503718715a 100644
--- a/documentation/wiki/ChangeWaves.md
+++ b/documentation/wiki/ChangeWaves.md
@@ -30,6 +30,7 @@ Change wave checks around features will be removed in the release that accompani
## Current Rotation of Change Waves
### 18.8
+- Use the Unix logical current directory from `PWD` when resolving relative project paths, so builds under symlinked directories produce logical project full paths and related output paths. Set `MSBUILDDISABLEFEATURESFROMVERSION=18.8` to opt out.
- [RAR task: across multiple input properties, resolve relative paths against the project directory (not the process current directory)](https://github.com/dotnet/msbuild/pull/13319)
- [Console, parallel console, and terminal loggers print the paths of log files written by registered loggers (e.g. file logger and binary logger) as part of the end-of-build summary.](https://github.com/dotnet/msbuild/pull/13577)
diff --git a/src/MSBuild.UnitTests/XMake_Tests.cs b/src/MSBuild.UnitTests/XMake_Tests.cs
index 7a12d44ea2c..a89eef4add8 100644
--- a/src/MSBuild.UnitTests/XMake_Tests.cs
+++ b/src/MSBuild.UnitTests/XMake_Tests.cs
@@ -1362,6 +1362,34 @@ public void ResponseFileInProjectDirectoryFoundImplicitly()
output.ShouldContain("[A=1]");
}
+ [UnixOnlyFact]
+ public void ResponseFileInLogicalProjectDirectoryFoundImplicitly()
+ {
+ string root = _env.CreateFolder().Path;
+ string realDirectory = Path.Combine(root, "repo", "project");
+ string logicalParentDirectory = Path.Combine(root, "links");
+ string linkDirectory = Path.Combine(logicalParentDirectory, "project");
+ Directory.CreateDirectory(realDirectory);
+ Directory.CreateDirectory(logicalParentDirectory);
+
+ string errorMessage = null;
+ NativeMethodsShared.MakeSymbolicLink(linkDirectory, realDirectory, ref errorMessage).ShouldBeTrue(errorMessage);
+
+ string content = ObjectModelHelpers.CleanupFileContents("");
+ File.WriteAllText(Path.Combine(realDirectory, "my.proj"), content);
+ File.WriteAllText(Path.Combine(root, "repo", "Directory.Build.rsp"), "/p:A=physical");
+ File.WriteAllText(Path.Combine(logicalParentDirectory, "Directory.Build.rsp"), "/p:A=logical");
+
+ _env.SetCurrentDirectory(linkDirectory);
+ _env.SetEnvironmentVariable("PWD", linkDirectory);
+
+ string output = RunnerUtilities.ExecMSBuild("my.proj", out var successfulExit, _output);
+ successfulExit.ShouldBeTrue();
+
+ output.ShouldContain("[A=logical]");
+ output.ShouldNotContain("[A=physical]");
+ }
+
[Fact]
public void ResponseFileSwitchesAppearInCommandLine()
{
@@ -1915,6 +1943,113 @@ public void TestProcessProjectSwitchReplicateBuildingDFLKG()
MSBuildApp.ProcessProjectSwitch(Array.Empty(), extensionsToIgnore, projectHelper.GetFiles).ShouldBe("test.proj"); // "Expected test.proj to be only project found"
}
+ [UnixOnlyFact]
+ public void ResolveProjectPathAgainstLogicalCurrentDirectoryPreservesSymlinkFromPwd()
+ {
+ string root = _env.CreateFolder().Path;
+ string realDirectory = Path.Combine(root, "real");
+ string linkDirectory = Path.Combine(root, "link");
+ Directory.CreateDirectory(realDirectory);
+
+ string errorMessage = null;
+ NativeMethodsShared.MakeSymbolicLink(linkDirectory, realDirectory, ref errorMessage).ShouldBeTrue(errorMessage);
+
+ _env.SetCurrentDirectory(linkDirectory);
+ _env.SetEnvironmentVariable("PWD", linkDirectory);
+
+ string relativeProjectPath = Path.Combine("MyApp", "MyApp.csproj");
+
+ MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath)
+ .ShouldBe(Path.Combine(linkDirectory, relativeProjectPath));
+ }
+
+ [UnixOnlyFact]
+ public void ResolveProjectPathAgainstLogicalCurrentDirectoryCanBeDisabledByChangeWave()
+ {
+ string root = _env.CreateFolder().Path;
+ string realDirectory = Path.Combine(root, "real");
+ string linkDirectory = Path.Combine(root, "link");
+ Directory.CreateDirectory(realDirectory);
+
+ string errorMessage = null;
+ NativeMethodsShared.MakeSymbolicLink(linkDirectory, realDirectory, ref errorMessage).ShouldBeTrue(errorMessage);
+
+ _env.SetCurrentDirectory(linkDirectory);
+ _env.SetEnvironmentVariable("PWD", linkDirectory);
+ _env.SetEnvironmentVariable("MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_8.ToString());
+ ChangeWaves.ResetStateForTests();
+
+ string relativeProjectPath = Path.Combine("MyApp", "MyApp.csproj");
+
+ MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath)
+ .ShouldBe(relativeProjectPath);
+ }
+
+ [UnixOnlyFact]
+ public void ResolveProjectPathAgainstLogicalCurrentDirectoryDoesNotRebaseToDifferentPhysicalPath()
+ {
+ string root = _env.CreateFolder().Path;
+ string realCurrentDirectory = Path.Combine(root, "repo", "current");
+ string realProjectDirectory = Path.Combine(root, "repo", "other");
+ string linkParentDirectory = Path.Combine(root, "links");
+ string linkDirectory = Path.Combine(linkParentDirectory, "link");
+ Directory.CreateDirectory(realCurrentDirectory);
+ Directory.CreateDirectory(realProjectDirectory);
+ Directory.CreateDirectory(linkParentDirectory);
+ File.WriteAllText(Path.Combine(realProjectDirectory, "MyApp.csproj"), "");
+
+ string errorMessage = null;
+ NativeMethodsShared.MakeSymbolicLink(linkDirectory, realCurrentDirectory, ref errorMessage).ShouldBeTrue(errorMessage);
+
+ _env.SetCurrentDirectory(linkDirectory);
+ _env.SetEnvironmentVariable("PWD", linkDirectory);
+
+ string relativeProjectPath = Path.Combine("..", "other", "MyApp.csproj");
+
+ MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath)
+ .ShouldBe(relativeProjectPath);
+ }
+
+ [UnixOnlyFact]
+ public void ResolveProjectPathAgainstLogicalCurrentDirectoryPreservesSiblingPathThroughSymlink()
+ {
+ string root = _env.CreateFolder().Path;
+ string realDirectory = Path.Combine(root, "real");
+ string realAppDirectory = Path.Combine(realDirectory, "App");
+ string realLibDirectory = Path.Combine(realDirectory, "Lib");
+ string linkDirectory = Path.Combine(root, "link");
+ Directory.CreateDirectory(realAppDirectory);
+ Directory.CreateDirectory(realLibDirectory);
+ File.WriteAllText(Path.Combine(realLibDirectory, "Lib.csproj"), "");
+
+ string errorMessage = null;
+ NativeMethodsShared.MakeSymbolicLink(linkDirectory, realDirectory, ref errorMessage).ShouldBeTrue(errorMessage);
+
+ string logicalAppDirectory = Path.Combine(linkDirectory, "App");
+ _env.SetCurrentDirectory(logicalAppDirectory);
+ _env.SetEnvironmentVariable("PWD", logicalAppDirectory);
+
+ string relativeProjectPath = Path.Combine("..", "Lib", "Lib.csproj");
+
+ MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath)
+ .ShouldBe(Path.Combine(linkDirectory, "Lib", "Lib.csproj"));
+ }
+
+ [UnixOnlyFact]
+ public void ResolveProjectPathAgainstLogicalCurrentDirectoryIgnoresMismatchedPwd()
+ {
+ string currentDirectory = _env.CreateFolder().Path;
+ string otherDirectory = _env.CreateFolder().Path;
+
+ _env.SetCurrentDirectory(currentDirectory);
+ _env.SetEnvironmentVariable("PWD", otherDirectory);
+
+ string relativeProjectPath = Path.Combine("MyApp", "MyApp.csproj");
+
+ MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(relativeProjectPath)
+ .ShouldBe(relativeProjectPath);
+ }
+
///
/// Test the case where we remove all of the project extensions that exist in the directory
///
diff --git a/src/MSBuild/CommandLine/CommandLineParser.cs b/src/MSBuild/CommandLine/CommandLineParser.cs
index 8a5802501ac..25aa30337f9 100644
--- a/src/MSBuild/CommandLine/CommandLineParser.cs
+++ b/src/MSBuild/CommandLine/CommandLineParser.cs
@@ -550,7 +550,7 @@ private static string GetProjectDirectory(string[] projectSwitchParameters)
if (projectSwitchParameters.Length == 1)
{
- var projectFile = FileUtilities.FixFilePath(projectSwitchParameters[0]);
+ var projectFile = MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(FileUtilities.FixFilePath(projectSwitchParameters[0]));
if (FileSystems.Default.DirectoryExists(projectFile))
{
@@ -563,6 +563,10 @@ private static string GetProjectDirectory(string[] projectSwitchParameters)
projectDirectory = Path.GetDirectoryName(Path.GetFullPath(projectFile));
}
}
+ else
+ {
+ projectDirectory = MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(projectDirectory);
+ }
return projectDirectory;
}
diff --git a/src/MSBuild/XMake.cs b/src/MSBuild/XMake.cs
index f726294145e..9f27e46d13c 100644
--- a/src/MSBuild/XMake.cs
+++ b/src/MSBuild/XMake.cs
@@ -361,7 +361,11 @@ private static bool CanRunServerBasedOnCommandLineSwitches(string[] commandLine)
{
commandLineSwitches = CombineSwitchesRespectingPriority(switchesFromAutoResponseFile, switchesNotFromAutoResponseFile, fullCommandLine);
}
- string projectFile = ProcessProjectSwitch(commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Project], commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.IgnoreProjectExtensions], Directory.GetFiles);
+ string projectFile = ResolveProjectPathAgainstLogicalCurrentDirectory(
+ ProcessProjectSwitch(
+ commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Project],
+ commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.IgnoreProjectExtensions],
+ Directory.GetFiles));
if (commandLineSwitches[CommandLineSwitches.ParameterlessSwitch.Help] ||
commandLineSwitches.IsParameterizedSwitchSet(CommandLineSwitches.ParameterizedSwitch.NodeMode) ||
commandLineSwitches[CommandLineSwitches.ParameterlessSwitch.Version] ||
@@ -2225,7 +2229,11 @@ private static bool ProcessCommandLineSwitches(
commandLine);
}
- projectFile = ProcessProjectSwitch(commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Project], commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.IgnoreProjectExtensions], Directory.GetFiles);
+ projectFile = ResolveProjectPathAgainstLogicalCurrentDirectory(
+ ProcessProjectSwitch(
+ commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Project],
+ commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.IgnoreProjectExtensions],
+ Directory.GetFiles));
// figure out which targets we are building
targets = ProcessTargetSwitch(commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Target]);
@@ -3239,6 +3247,104 @@ internal static string ProcessProjectSwitch(
return projectFile;
}
+ ///
+ /// On Unix, rebases a relative project path onto the shell's logical working directory ($PWD)
+ /// when $PWD physically resolves to the same directory as .
+ /// This makes MSBuild produce the same $(MSBuildProjectFullPath) (and therefore the same
+ /// intermediate/output paths) regardless of whether the user reached the project via an absolute path
+ /// through a symlink or via a relative path under a symlinked working directory.
+ ///
+ ///
+ /// No-op on Windows (where GetCurrentDirectory already returns the as-typed path), when
+ /// is absolute, when PWD is unset/relative, or when PWD's
+ /// physical target differs from getcwd() (defense against a stale or mismatched PWD).
+ /// Gated by so users can opt out via
+ /// MSBuildDisableFeaturesFromVersion if the change breaks an existing workflow.
+ ///
+ internal static string ResolveProjectPathAgainstLogicalCurrentDirectory(string projectFile)
+ {
+ if (!ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_8)
+ || NativeMethodsShared.IsWindows
+ || string.IsNullOrEmpty(projectFile)
+ || Path.IsPathRooted(projectFile))
+ {
+ return projectFile;
+ }
+
+ string logicalCurrentDirectory = Environment.GetEnvironmentVariable("PWD");
+ if (string.IsNullOrEmpty(logicalCurrentDirectory) || !Path.IsPathRooted(logicalCurrentDirectory))
+ {
+ return projectFile;
+ }
+
+ string currentDirectory = Directory.GetCurrentDirectory();
+ if (!IsSamePhysicalDirectory(logicalCurrentDirectory, currentDirectory))
+ {
+ return projectFile;
+ }
+
+ string logicalProjectFile = Path.GetFullPath(Path.Combine(logicalCurrentDirectory, projectFile));
+
+ // A relative path without ".." segments can never lexically escape the shared physical prefix,
+ // so rebasing onto PWD is always safe. With ".." segments, lexical normalization may collapse
+ // the path above the prefix before any symlink resolution happens, so the logical and physical
+ // resolutions can land on different files. In that case, only rebase when both resolutions
+ // refer to the same physical target.
+ if (projectFile.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains(".."))
+ {
+ string currentProjectFile = Path.GetFullPath(projectFile);
+ if (!IsSamePhysicalPath(logicalProjectFile, currentProjectFile))
+ {
+ return projectFile;
+ }
+ }
+
+ return logicalProjectFile;
+ }
+
+ private static bool IsSamePhysicalPath(string firstPath, string secondPath)
+ {
+ if (FileSystems.Default.DirectoryExists(firstPath) && FileSystems.Default.DirectoryExists(secondPath))
+ {
+ return IsSamePhysicalDirectory(firstPath, secondPath);
+ }
+
+ if (FileSystems.Default.FileExists(firstPath) && FileSystems.Default.FileExists(secondPath))
+ {
+ string firstDirectory = Path.GetDirectoryName(firstPath);
+ string secondDirectory = Path.GetDirectoryName(secondPath);
+
+ return string.Equals(Path.GetFileName(firstPath), Path.GetFileName(secondPath), StringComparison.Ordinal) &&
+ IsSamePhysicalDirectory(firstDirectory, secondDirectory);
+ }
+
+ return false;
+ }
+
+ // Compares two directories for physical-path equivalence by canonicalizing each through getcwd().
+ // .NET does not expose POSIX realpath(3), and Path.GetFullPath only normalizes lexically (does not
+ // resolve symlinks). Mutating the process cwd is safe here because callers run on the single-threaded
+ // command-line parsing path before any build worker threads or child processes are started.
+ private static bool IsSamePhysicalDirectory(string firstDirectory, string secondDirectory)
+ {
+ string savedCurrentDirectory = Directory.GetCurrentDirectory();
+ try
+ {
+ Directory.SetCurrentDirectory(firstDirectory);
+ string resolvedFirstDirectory = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(secondDirectory);
+ return string.Equals(Directory.GetCurrentDirectory(), resolvedFirstDirectory, StringComparison.Ordinal);
+ }
+ catch (Exception ex) when (ExceptionHandling.IsIoRelatedException(ex))
+ {
+ return false;
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(savedCurrentDirectory);
+ }
+ }
+
private static void ValidateExtensions(string[] projectExtensionsToIgnore)
{
if (projectExtensionsToIgnore?.Length > 0)