Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/wiki/ChangeWaves.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
135 changes: 135 additions & 0 deletions src/MSBuild.UnitTests/XMake_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<Project ToolsVersion='msbuilddefaulttoolsversion' xmlns='msbuildnamespace'><Target Name='t'><Warning Text='[A=$(A)]'/></Target></Project>");
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()
{
Expand Down Expand Up @@ -1915,6 +1943,113 @@ public void TestProcessProjectSwitchReplicateBuildingDFLKG()
MSBuildApp.ProcessProjectSwitch(Array.Empty<string>(), 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);
Comment on lines +1946 to +1952

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"), "<Project />");

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"), "<Project />");

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);
}

/// <summary>
/// Test the case where we remove all of the project extensions that exist in the directory
/// </summary>
Expand Down
6 changes: 5 additions & 1 deletion src/MSBuild/CommandLine/CommandLineParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand All @@ -563,6 +563,10 @@ private static string GetProjectDirectory(string[] projectSwitchParameters)
projectDirectory = Path.GetDirectoryName(Path.GetFullPath(projectFile));
}
}
else
{
projectDirectory = MSBuildApp.ResolveProjectPathAgainstLogicalCurrentDirectory(projectDirectory);
}

return projectDirectory;
}
Expand Down
110 changes: 108 additions & 2 deletions src/MSBuild/XMake.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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] ||
Expand Down Expand Up @@ -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],
Comment on lines +2232 to +2234
commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.IgnoreProjectExtensions],
Directory.GetFiles));

// figure out which targets we are building
targets = ProcessTargetSwitch(commandLineSwitches[CommandLineSwitches.ParameterizedSwitch.Target]);
Expand Down Expand Up @@ -3239,6 +3247,104 @@ internal static string ProcessProjectSwitch(
return projectFile;
}

/// <summary>
/// On Unix, rebases a relative project path onto the shell's logical working directory (<c>$PWD</c>)
/// when <c>$PWD</c> physically resolves to the same directory as <see cref="Directory.GetCurrentDirectory"/>.
/// This makes MSBuild produce the same <c>$(MSBuildProjectFullPath)</c> (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.
/// </summary>
/// <remarks>
/// No-op on Windows (where <c>GetCurrentDirectory</c> already returns the as-typed path), when
/// <paramref name="projectFile"/> is absolute, when <c>PWD</c> is unset/relative, or when <c>PWD</c>'s
/// physical target differs from <c>getcwd()</c> (defense against a stale or mismatched <c>PWD</c>).
/// Gated by <see cref="ChangeWaves.Wave18_8"/> so users can opt out via
/// <c>MSBuildDisableFeaturesFromVersion</c> if the change breaks an existing workflow.
/// </remarks>
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)
Expand Down