Issue Description
On Linux, when a project is on a symlink path, the same project produces different output paths depending on whether MSBuild receives the project path as absolute or relative:
$ dotnet msbuild /tmp/link/Lib.Tests/Lib.Tests.csproj -getProperty:TargetDir
/tmp/link/Lib.Tests/bin/Debug/net10.0/ ← absolute symlink path: preserved
$ cd /tmp/link && dotnet msbuild Lib.Tests -getProperty:TargetDir
/tmp/real/Lib.Tests/bin/Debug/net10.0/ ← relative path: resolved via CWD!
This inconsistency causes downstream failures in any tooling that compares paths from MSBuild with paths from the filesystem/workspace (e.g., VS Code C# Dev Kit test discovery fails because file watcher events at /tmp/link/... don't match project output paths at /tmp/real/...).
Steps to Reproduce
Repro 1
- Create a project directory and a symlink to it
mkdir /tmp/real
ln -s /tmp/real /tmp/link
- Create any .NET project from the symlink path
cd /tmp/link
dotnet new console -o MyApp
- Compare MSBuild property output
# Absolute symlink path — symlink preserved:
dotnet msbuild /tmp/link/MyApp/MyApp.csproj -getProperty:MSBuildProjectDirectory
# Output: /tmp/link/MyApp
dotnet msbuild /tmp/link/MyApp/MyApp.csproj -getProperty:TargetDir
# Output: /tmp/link/MyApp/bin/Debug/net10.0/
# Relative path from symlink CWD — symlink resolved:
cd /tmp/link && dotnet msbuild MyApp -getProperty:MSBuildProjectDirectory
# Output: /tmp/real/MyApp
cd /tmp/link && dotnet msbuild MyApp -getProperty:TargetDir
# Output: /tmp/real/MyApp/bin/Debug/net10.0/
Repro 2
Two dotnet new projects, one symlink, two dotnet build invocations.
mkdir -p /tmp/msbuild-symlink-repro && cd /tmp/msbuild-symlink-repro
# Create a directory and a symlink that points at it.
rm -rf real link
mkdir real
ln -s real link
# Inside the real directory, create a classlib and a console app that
# references it.
cd real
dotnet new classlib -n Lib -o Lib --force > /dev/null
dotnet new console -n App -o App --force > /dev/null
cd App && dotnet add reference ../Lib/Lib.csproj > /dev/null
cd /tmp/msbuild-symlink-repro
# Step 1: build Lib through the real path. This populates
# Lib/obj/Debug/<tfm>/ref/Lib.dll AND writes
# Lib/obj/Debug/<tfm>/Lib.csproj.FileListAbsolute.txt with paths under
# /tmp/msbuild-symlink-repro/real/...
dotnet build /tmp/msbuild-symlink-repro/real/Lib/Lib.csproj
# Step 2: build App through the SYMLINKED path. This re-builds Lib as a
# project reference; the new build records FileWrites under
# /tmp/msbuild-symlink-repro/link/...; IncrementalClean compares those to
# the cached entries (still under /real/...) and deletes
# Lib/obj/Debug/<tfm>/ref/Lib.dll. csc then fails with CS0006.
dotnet build /tmp/msbuild-symlink-repro/link/App/App.csproj
Expected Behavior
MSBuild should produce the same project and output paths for a given project regardless of whether the project is passed as an absolute path or a relative path. If the user invokes MSBuild from a symlinked working directory, properties such as MSBuildProjectDirectory, TargetDir, and TargetPath should consistently preserve that logical path.
Actual Behavior
On Linux, when the project is passed as a relative path, MSBuild resolves it against the canonical current directory and returns paths under the real filesystem location instead of the symlink path. When the same project is passed as an absolute path containing the symlink, MSBuild preserves the symlink path, so the same build produces different property values depending on how the project path was supplied.
Analysis
I encountered this problem in VS Code when opening a dotnet project that uses XUnit V3 from a symlink on a WSL instance. (The XUnit V3 is the important part, because it uses .exe test projects instead of .dll, so the test discovery in the VS Code C# Dev Kit is different.) The test discovery and execution broke in multiple different ways that seemed to be timing issues. I could see canonical paths in the debug output, and I spent a lot of time in Copilot prompts trying to find the root cause.
As a workaround, everything loaded when I launched the project in a non-symlinked folder.
I don't know for sure, but this is what I think: .NET's Directory.GetCurrentDirectory() calls the POSIX getcwd(2) syscall, which always resolves symlinks on Linux. When MSBuild (or the dotnet CLI) resolves a relative project path, it joins it with the resolved CWD:
$ cd /tmp/link && dotnet run --project /tmp/cwdtest
Directory.GetCurrentDirectory(): /tmp/real ← kernel resolves this
Path.GetFullPath("MyApp/MyApp.csproj"): /tmp/real/MyApp/MyApp.csproj
Path.GetFullPath("/tmp/link/MyApp/MyApp.csproj"): /tmp/link/MyApp/MyApp.csproj ← absolute preserved
This is from getcwd(2). Shells like bash work around this by tracking the logical path in $PWD, but .NET does not use $PWD. I don't know if using $PWD is the right thing to use for this or not.
Any tooling that uses MSBuild properties and also interacts with the filesystem via symlink paths will see mismatched paths.
Versions & Configurations
- OS: Linux (Ubuntu)
- .NET SDK: 10.0.201
- Target Framework: net10.0
$ dotnet msbuild --version
18.3.0.15422
Issue Description
On Linux, when a project is on a symlink path, the same project produces different output paths depending on whether MSBuild receives the project path as absolute or relative:
This inconsistency causes downstream failures in any tooling that compares paths from MSBuild with paths from the filesystem/workspace (e.g., VS Code C# Dev Kit test discovery fails because file watcher events at
/tmp/link/...don't match project output paths at/tmp/real/...).Steps to Reproduce
Repro 1
cd /tmp/link dotnet new console -o MyAppRepro 2
Two
dotnet newprojects, one symlink, twodotnet buildinvocations.Expected Behavior
MSBuild should produce the same project and output paths for a given project regardless of whether the project is passed as an absolute path or a relative path. If the user invokes MSBuild from a symlinked working directory, properties such as MSBuildProjectDirectory, TargetDir, and TargetPath should consistently preserve that logical path.
Actual Behavior
On Linux, when the project is passed as a relative path, MSBuild resolves it against the canonical current directory and returns paths under the real filesystem location instead of the symlink path. When the same project is passed as an absolute path containing the symlink, MSBuild preserves the symlink path, so the same build produces different property values depending on how the project path was supplied.
Analysis
I encountered this problem in VS Code when opening a dotnet project that uses XUnit V3 from a symlink on a WSL instance. (The XUnit V3 is the important part, because it uses .exe test projects instead of .dll, so the test discovery in the VS Code C# Dev Kit is different.) The test discovery and execution broke in multiple different ways that seemed to be timing issues. I could see canonical paths in the debug output, and I spent a lot of time in Copilot prompts trying to find the root cause.
As a workaround, everything loaded when I launched the project in a non-symlinked folder.
I don't know for sure, but this is what I think: .NET's
Directory.GetCurrentDirectory()calls the POSIXgetcwd(2)syscall, which always resolves symlinks on Linux. When MSBuild (or the dotnet CLI) resolves a relative project path, it joins it with the resolved CWD:This is from
getcwd(2). Shells like bash work around this by tracking the logical path in$PWD, but .NET does not use$PWD. I don't know if using $PWD is the right thing to use for this or not.Any tooling that uses MSBuild properties and also interacts with the filesystem via symlink paths will see mismatched paths.
Versions & Configurations
$ dotnet msbuild --version
18.3.0.15422