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/Framework/NativeMethods.cs b/src/Framework/NativeMethods.cs
index 915cf0673e4..1fb1c20bbeb 100644
--- a/src/Framework/NativeMethods.cs
+++ b/src/Framework/NativeMethods.cs
@@ -1325,6 +1325,49 @@ internal static void RestoreConsoleMode(uint? originalConsoleMode, bool useStand
[DllImport("libc", SetLastError = true)]
internal static extern int symlink(string oldpath, string newpath);
+ [DllImport("libc", EntryPoint = "realpath", SetLastError = true)]
+ private static extern IntPtr realpath_native(string path, IntPtr resolved);
+
+ [DllImport("libc", EntryPoint = "free")]
+ private static extern void libc_free(IntPtr ptr);
+
+ ///
+ /// Resolves to its canonical form via POSIX realpath(3), following
+ /// symlinks. Returns null on Windows, on null/empty input, or when the call fails.
+ /// Unlike , this resolves symlinks against the real
+ /// filesystem without mutating process-global state, so it is safe to call from any thread.
+ ///
+ internal static string RealPath(string path)
+ {
+ if (IsWindows || string.IsNullOrEmpty(path))
+ {
+ return null;
+ }
+
+ IntPtr ptr = IntPtr.Zero;
+ try
+ {
+ ptr = realpath_native(path, IntPtr.Zero);
+ if (ptr == IntPtr.Zero)
+ {
+ return null;
+ }
+#if NET
+ return Marshal.PtrToStringUTF8(ptr);
+#else
+ return Marshal.PtrToStringAnsi(ptr);
+#endif
+ }
+ finally
+ {
+ if (ptr != IntPtr.Zero)
+ {
+ // realpath() with NULL second arg returns a malloc()'d buffer; caller must free().
+ libc_free(ptr);
+ }
+ }
+ }
+
#if FEATURE_WINDOWSINTEROP
[SupportedOSPlatform("windows6.1")]
internal static unsafe bool SetThreadErrorMode(int newMode, out int oldMode)
diff --git a/src/MSBuild.UnitTests/XMake_Tests.cs b/src/MSBuild.UnitTests/XMake_Tests.cs
index 7a12d44ea2c..31f32f7f968 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,48 @@ 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);
+ }
+
///
/// Test the case where we remove all of the project extensions that exist in the directory
///
diff --git a/src/MSBuild/XMake.cs b/src/MSBuild/XMake.cs
index f726294145e..c6dbdb682b1 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,50 @@ internal static string ProcessProjectSwitch(
return projectFile;
}
+ ///
+ /// On Unix, rebases a relative project path onto $PWD when $PWD physically resolves
+ /// to the same directory as . This makes
+ /// $(MSBuildProjectFullPath) (and dependent output paths) stable whether the user reached
+ /// the project through a symlinked working directory or via the kernel-resolved absolute path.
+ ///
+ ///
+ /// No-op on Windows, when is absolute or contains ..,
+ /// when PWD is unset/relative/stale, or when the change wave is disabled via
+ /// MSBuildDisableFeaturesFromVersion=18.8. Uses
+ /// (POSIX realpath(3)) so no process-global state is mutated; safe under MSBuild Server,
+ /// multithreaded MSBuild, and hosted scenarios.
+ ///
+ internal static string ResolveProjectPathAgainstLogicalCurrentDirectory(string projectFile)
+ {
+ if (!ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_8)
+ || NativeMethodsShared.IsWindows
+ || string.IsNullOrEmpty(projectFile)
+ || Path.IsPathRooted(projectFile)
+ || projectFile.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains(".."))
+ {
+ // Bail on ".." segments: lexical normalization can escape the shared physical prefix,
+ // so the logical and physical resolutions can end up pointing at different files.
+ return projectFile;
+ }
+
+ string logicalCurrentDirectory = Environment.GetEnvironmentVariable("PWD");
+ if (string.IsNullOrEmpty(logicalCurrentDirectory) || !Path.IsPathRooted(logicalCurrentDirectory))
+ {
+ return projectFile;
+ }
+
+ // Confirm $PWD physically resolves to the same directory as getcwd(); otherwise PWD is stale.
+ string physicalPwd = NativeMethodsShared.RealPath(logicalCurrentDirectory);
+ string physicalCwd = NativeMethodsShared.RealPath(Directory.GetCurrentDirectory());
+ if (physicalPwd == null || physicalCwd == null
+ || !string.Equals(physicalPwd, physicalCwd, StringComparison.Ordinal))
+ {
+ return projectFile;
+ }
+
+ return Path.GetFullPath(Path.Combine(logicalCurrentDirectory, projectFile));
+ }
+
private static void ValidateExtensions(string[] projectExtensionsToIgnore)
{
if (projectExtensionsToIgnore?.Length > 0)