diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs index 7721153da981..1ea2e2e68235 100644 --- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs @@ -847,17 +847,18 @@ Building because previous global properties count ({previousCacheEntry.GlobalPro } // Check that the source file is not modified. - if (entryPointFile.LastWriteTimeUtc > buildTimeUtc) + var targetFile = ResolveLinkTargetOrSelf(entryPointFile); + if (targetFile.LastWriteTimeUtc > buildTimeUtc) { cache.CanUseCscViaPreviousArguments = true; - Reporter.Verbose.WriteLine("Compiling because entry point file is modified: " + entryPointFile.FullName); + Reporter.Verbose.WriteLine("Compiling because entry point file is modified: " + targetFile.FullName); return true; } // Check that implicit build files are not modified. foreach (var implicitBuildFilePath in previousCacheEntry.ImplicitBuildFiles) { - var implicitBuildFileInfo = new FileInfo(implicitBuildFilePath); + var implicitBuildFileInfo = ResolveLinkTargetOrSelf(new FileInfo(implicitBuildFilePath)); if (!implicitBuildFileInfo.Exists || implicitBuildFileInfo.LastWriteTimeUtc > buildTimeUtc) { Reporter.Verbose.WriteLine("Building because implicit build file is missing or modified: " + implicitBuildFileInfo.FullName); @@ -876,6 +877,16 @@ Building because previous global properties count ({previousCacheEntry.GlobalPro } return false; + + static FileSystemInfo ResolveLinkTargetOrSelf(FileSystemInfo fileSystemInfo) + { + if (!fileSystemInfo.Exists) + { + return fileSystemInfo; + } + + return fileSystemInfo.ResolveLinkTarget(returnFinalTarget: true) ?? fileSystemInfo; + } } private static RunFileBuildCacheEntry? DeserializeCacheEntry(string path) diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs index ebe57115cb42..7556c10de1d5 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -1021,6 +1021,62 @@ public void DirectoryBuildProps() .And.HaveStdOut("Hello from TestName"); } + /// + /// Implicit build files are taken from the folder of the symbolic link itself, not its target. + /// This is equivalent to the behavior of symlinked project files. + /// See . + /// + [Fact] + public void DirectoryBuildProps_SymbolicLink() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + var dir1 = Path.Join(testInstance.Path, "dir1"); + Directory.CreateDirectory(dir1); + + var originalPath = Path.Join(dir1, "original.cs"); + File.WriteAllText(originalPath, s_program); + + File.WriteAllText(Path.Join(dir1, "Directory.Build.props"), """ + + + OriginalAssemblyName + + + """); + + var dir2 = Path.Join(testInstance.Path, "dir2"); + Directory.CreateDirectory(dir2); + + var programFileName = "linked.cs"; + var programPath = Path.Join(dir2, programFileName); + + File.CreateSymbolicLink(path: programPath, pathToTarget: originalPath); + + File.WriteAllText(Path.Join(dir2, "Directory.Build.props"), """ + + + LinkedAssemblyName + + + """); + + new DotnetCommand(Log, "run", programFileName) + .WithWorkingDirectory(dir2) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from LinkedAssemblyName"); + + // Removing the Directory.Build.props should be detected by up-to-date check. + File.Delete(Path.Join(dir2, "Directory.Build.props")); + + new DotnetCommand(Log, "run", programFileName) + .WithWorkingDirectory(dir2) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from linked"); + } + /// /// Overriding default (implicit) properties of file-based apps via implicit build files. /// @@ -3403,6 +3459,82 @@ public void UpToDate_InvalidOptions() .And.HaveStdErrContaining(string.Format(CliCommandStrings.CannotCombineOptions, RunCommandParser.NoCacheOption.Name, RunCommandParser.NoBuildOption.Name)); } + /// + /// optimization should see through symlinks. + /// See . + /// + [Fact] + public void UpToDate_SymbolicLink() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + var originalPath = Path.Join(testInstance.Path, "original.cs"); + var code = """ + #!/usr/bin/env dotnet + Console.WriteLine("v1"); + """; + var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + File.WriteAllText(originalPath, code, utf8NoBom); + + var programFileName = "linked"; + var programPath = Path.Join(testInstance.Path, programFileName); + + File.CreateSymbolicLink(path: programPath, pathToTarget: originalPath); + + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuildingCommand.GetArtifactsPath(programPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + + Build(testInstance, BuildLevel.All, expectedOutput: "v1", programFileName: programFileName); + + Build(testInstance, BuildLevel.None, expectedOutput: "v1", programFileName: programFileName); + + code = code.Replace("v1", "v2"); + File.WriteAllText(originalPath, code, utf8NoBom); + + Build(testInstance, BuildLevel.Csc, expectedOutput: "v2", programFileName: programFileName); + } + + /// + /// Similar to but with a chain of symlinks. + /// + [Fact] + public void UpToDate_SymbolicLink2() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + var originalPath = Path.Join(testInstance.Path, "original.cs"); + var code = """ + #!/usr/bin/env dotnet + Console.WriteLine("v1"); + """; + var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + File.WriteAllText(originalPath, code, utf8NoBom); + + var intermediateFileName = "linked1"; + var intermediatePath = Path.Join(testInstance.Path, intermediateFileName); + + File.CreateSymbolicLink(path: intermediatePath, pathToTarget: originalPath); + + var programFileName = "linked2"; + var programPath = Path.Join(testInstance.Path, programFileName); + + File.CreateSymbolicLink(path: programPath, pathToTarget: intermediatePath); + + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuildingCommand.GetArtifactsPath(programPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + + Build(testInstance, BuildLevel.All, expectedOutput: "v1", programFileName: programFileName); + + Build(testInstance, BuildLevel.None, expectedOutput: "v1", programFileName: programFileName); + + code = code.Replace("v1", "v2"); + File.WriteAllText(originalPath, code, utf8NoBom); + + Build(testInstance, BuildLevel.Csc, expectedOutput: "v2", programFileName: programFileName); + } + /// /// Up-to-date checks and optimizations currently don't support other included files. /// @@ -3712,6 +3844,41 @@ Hello from Program """); } + /// + /// Combination of and . + /// + [Fact] + public void CscOnly_SymbolicLink() + { + var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); + + var originalPath = Path.Join(testInstance.Path, "original.cs"); + var code = """ + #!/usr/bin/env dotnet + Console.WriteLine("v1"); + """; + var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + File.WriteAllText(originalPath, code, utf8NoBom); + + var programFileName = "linked"; + var programPath = Path.Join(testInstance.Path, programFileName); + + File.CreateSymbolicLink(path: programPath, pathToTarget: originalPath); + + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuildingCommand.GetArtifactsPath(programPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + + Build(testInstance, BuildLevel.Csc, expectedOutput: "v1", programFileName: programFileName); + + Build(testInstance, BuildLevel.None, expectedOutput: "v1", programFileName: programFileName); + + code = code.Replace("v1", "v2"); + File.WriteAllText(originalPath, code, utf8NoBom); + + Build(testInstance, BuildLevel.Csc, expectedOutput: "v2", programFileName: programFileName); + } + /// /// Tests an optimization which remembers CSC args from prior MSBuild runs and can skip subsequent MSBuild invocations and call CSC directly. /// This optimization kicks in when the file has some #: directives (then the simpler "hard-coded CSC args" optimization cannot be used). @@ -3871,6 +4038,40 @@ public void CscOnly_AfterMSBuild_HardLinks() Build(testInstance, BuildLevel.Csc, expectedOutput: "Hi from Program"); } + /// + /// Combination of and . + /// + [Fact] + public void CscOnly_AfterMSBuild_SymbolicLink() + { + var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); + + var originalPath = Path.Join(testInstance.Path, "original.cs"); + var code = """ + #!/usr/bin/env dotnet + #:property Configuration=Release + Console.WriteLine("v1"); + """; + var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + File.WriteAllText(originalPath, code, utf8NoBom); + + var programFileName = "linked"; + var programPath = Path.Join(testInstance.Path, programFileName); + + File.CreateSymbolicLink(path: programPath, pathToTarget: originalPath); + + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuildingCommand.GetArtifactsPath(programPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + + Build(testInstance, BuildLevel.All, expectedOutput: "v1", programFileName: programFileName); + + code = code.Replace("v1", "v2"); + File.WriteAllText(originalPath, code, utf8NoBom); + + Build(testInstance, BuildLevel.Csc, expectedOutput: "v2", programFileName: programFileName); + } + /// /// See . /// This optimization currently does not support #:project references and hence is disabled if those are present. @@ -4398,6 +4599,35 @@ public void EntryPointFilePath_WithUnicodeCharacters() .And.HaveStdOut($"EntryPointFilePath: {filePath}"); } + [Fact] + public void EntryPointFilePath_SymbolicLink() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + var fileName = "Program.cs"; + var programPath = Path.Join(testInstance.Path, fileName); + File.WriteAllText(programPath, """ + #!/usr/bin/env dotnet + var entryPointFilePath = AppContext.GetData("EntryPointFilePath") as string; + Console.WriteLine($"EntryPointFilePath: {entryPointFilePath}"); + """); + + new DotnetCommand(Log, "run", fileName) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut($"EntryPointFilePath: {programPath}"); + + var linkName = "linked"; + var linkPath = Path.Join(testInstance.Path, linkName); + File.CreateSymbolicLink(linkPath, programPath); + + new DotnetCommand(Log, "run", linkName) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut($"EntryPointFilePath: {linkPath}"); + } + [Fact] public void MSBuildGet_Simple() {