diff --git a/src/Common/Utilities/PathUtilities.cs b/src/Common/Utilities/PathUtilities.cs index 2b21172688..ba5ce1b822 100644 --- a/src/Common/Utilities/PathUtilities.cs +++ b/src/Common/Utilities/PathUtilities.cs @@ -50,5 +50,50 @@ public static string EndWithSeparator(this string path) public static string EndWithSeparator(this string path, char separator) => path.EndsWithSeparator() ? path : path + separator; + + /// + /// Determines whether is fully qualified (independent of the process current + /// directory and drive). Unlike , rejects Windows drive-relative + /// (C:foo) and root-relative (\foo) paths. Polyfills Path.IsPathFullyQualified, + /// which is unavailable on .NET Framework. + /// + public static bool IsPathFullyQualified(string path) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + +#if NETFRAMEWORK + // Mirrors System.IO.PathInternal.IsPartiallyQualified (Windows). + if (path.Length < 2) + { + // A single character (or empty) can't be fixed; it is relative. + return false; + } + + if (IsDirectorySeparator(path[0])) + { + // A leading separator is only fully qualified for UNC (\\server) or device (\\?\) paths. + return path[1] == '?' || IsDirectorySeparator(path[1]); + } + + // The only other fixed form is drive-qualified: "X:\" or "X:/". + return path.Length >= 3 + && path[1] == Path.VolumeSeparatorChar + && IsDirectorySeparator(path[2]) + && IsValidDriveChar(path[0]); +#else + return Path.IsPathFullyQualified(path); +#endif + } + +#if NETFRAMEWORK + private static bool IsDirectorySeparator(char c) + => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + + private static bool IsValidDriveChar(char c) + => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); +#endif } } diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/AssemblyInfo.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/AssemblyInfo.cs new file mode 100644 index 0000000000..f0c76b68de --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the License.txt file in the project root for more information. + +using Xunit; + +// Some tests in this assembly temporarily change the process current working directory to validate that +// repository discovery resolves paths against the task's project directory rather than the process CWD. +// Disable test parallelization in this assembly so that the CWD mutation can't race with other tests. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/LocateRepositoryTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/LocateRepositoryTests.cs new file mode 100644 index 0000000000..a3b3dcbdf0 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/LocateRepositoryTests.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the License.txt file in the project root for more information. + +using TestUtilities; +using Xunit; + +namespace Microsoft.Build.Tasks.Git.UnitTests +{ + public class LocateRepositoryTests + { + /// + /// Verifies historical behavior is preserved: a fully-qualified + /// pointing at a git repository is located and its outputs are populated. + /// + [Fact] + public void AbsolutePath_LocatesRepository() + { + using var temp = new TempRoot(); + var (repoDir, gitDir) = CreateMinimalRepository(temp); + + var engine = new MockEngine(); + var task = new LocateRepository + { + BuildEngine = engine, + NoWarnOnMissingInfo = true, + Path = repoDir.Path, + }; + + Assert.True(task.Execute(), engine.Log); + Assert.Equal(repoDir.Path, task.WorkingDirectory); + Assert.Equal(gitDir.Path, task.RepositoryId); + } + + /// + /// Verifies that a non-fully-qualified (here: relative) is rejected. + /// + /// This is the multithreaded (MT) task model behavior: the process CWD is shared across projects, so it + /// must never influence repository discovery. The migrated task requires a fully-qualified path; a relative + /// path (".") is rejected and reported as the "missing repository" warning instead of being resolved + /// against the process current working directory. + /// + [Fact] + public void RelativePath_IsRejected() + { + var engine = new MockEngine(); + var task = new LocateRepository + { + BuildEngine = engine, + Path = ".", + }; + + // Not an error: repository discovery degrades to a warning. + Assert.True(task.Execute(), engine.Log); + Assert.Null(task.WorkingDirectory); + Assert.Contains("WARNING", engine.Log); + } + + private static (TempDirectory repoDir, TempDirectory gitDir) CreateMinimalRepository(TempRoot temp) + { + var repoDir = temp.CreateDirectory(); + var gitDir = repoDir.CreateDirectory(".git"); + gitDir.CreateFile("HEAD").WriteAllText("ref: refs/heads/main\n"); + gitDir.CreateFile("config").WriteAllText(""); + return (repoDir, gitDir); + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/PathUtilitiesTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/PathUtilitiesTests.cs new file mode 100644 index 0000000000..d36f386f30 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/PathUtilitiesTests.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the License.txt file in the project root for more information. +using System; +using Microsoft.Build.Tasks.SourceControl; +using TestUtilities; +using Xunit; + +namespace Microsoft.Build.Tasks.Git.UnitTests +{ + public class PathUtilitiesTests + { + [ConditionalTheory(typeof(WindowsOnly))] + [InlineData(@"C:\")] + [InlineData(@"C:\repo")] + [InlineData(@"C:/repo")] + [InlineData(@"c:\repo")] + [InlineData(@"Z:\a\b")] + [InlineData(@"\\server\share")] + [InlineData(@"//server/share")] + [InlineData(@"\\?\C:\repo")] + public void IsPathFullyQualified_Windows_True(string path) + => Assert.True(PathUtilities.IsPathFullyQualified(path)); + + [ConditionalTheory(typeof(WindowsOnly))] + [InlineData(@"C:foo")] // drive-relative: depends on the current directory of drive C + [InlineData(@"\foo")] // root-relative: depends on the current drive + [InlineData(@"/foo")] // root-relative (alt separator) + [InlineData(@"C:")] // drive with no path + [InlineData(@"1:\foo")] // invalid drive character + public void IsPathFullyQualified_Windows_False(string path) + => Assert.False(PathUtilities.IsPathFullyQualified(path)); + + [ConditionalTheory(typeof(UnixOnly))] + [InlineData(@"/")] + [InlineData(@"/foo")] + [InlineData(@"/foo/bar")] + public void IsPathFullyQualified_Unix_True(string path) + => Assert.True(PathUtilities.IsPathFullyQualified(path)); + + [ConditionalTheory(typeof(UnixOnly))] + [InlineData(@"C:\foo")] // not a special form on Unix + [InlineData(@"foo")] + public void IsPathFullyQualified_Unix_False(string path) + => Assert.False(PathUtilities.IsPathFullyQualified(path)); + + [Theory] + [InlineData("")] + [InlineData("foo")] + [InlineData("foo/bar")] + [InlineData(".")] + [InlineData("..")] + public void IsPathFullyQualified_Relative_False(string path) + => Assert.False(PathUtilities.IsPathFullyQualified(path)); + + [Fact] + public void IsPathFullyQualified_Null_Throws() + => Assert.Throws(() => PathUtilities.IsPathFullyQualified(null!)); + } +} diff --git a/src/Microsoft.Build.Tasks.Git/LocateRepository.cs b/src/Microsoft.Build.Tasks.Git/LocateRepository.cs index 8a4ab4bc1a..8fa48dbe96 100644 --- a/src/Microsoft.Build.Tasks.Git/LocateRepository.cs +++ b/src/Microsoft.Build.Tasks.Git/LocateRepository.cs @@ -8,6 +8,7 @@ namespace Microsoft.Build.Tasks.Git { + [MSBuildMultiThreadableTask] public sealed class LocateRepository : RepositoryTask { public string? RemoteName { get; set; } diff --git a/src/Microsoft.Build.Tasks.Git/RepositoryTask.cs b/src/Microsoft.Build.Tasks.Git/RepositoryTask.cs index efb060c5af..8e11ea21fe 100644 --- a/src/Microsoft.Build.Tasks.Git/RepositoryTask.cs +++ b/src/Microsoft.Build.Tasks.Git/RepositoryTask.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using Microsoft.Build.Framework; +using Microsoft.Build.Tasks.SourceControl; using Microsoft.Build.Utilities; namespace Microsoft.Build.Tasks.Git @@ -114,6 +115,14 @@ private void ExecuteImpl() var initialPath = GetInitialPath(); + // Repository discovery must not depend on the shared process CWD/drive under the multithreaded task + // model, so reject a non-fully-qualified path with the standard "missing repository" warning. + if (string.IsNullOrEmpty(initialPath) || !PathUtilities.IsPathFullyQualified(initialPath)) + { + ReportMissingRepositoryWarning(initialPath); + return null; + } + if (!GitRepository.TryFindRepository(initialPath, out var location)) { ReportMissingRepositoryWarning(initialPath); diff --git a/src/TestUtilities/MockEngine.cs b/src/TestUtilities/MockEngine.cs index 3fcb3ad129..6cb66efc96 100644 --- a/src/TestUtilities/MockEngine.cs +++ b/src/TestUtilities/MockEngine.cs @@ -4,16 +4,19 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Text; using Microsoft.Build.Framework; namespace TestUtilities { - public sealed class MockEngine : IBuildEngine + public sealed class MockEngine : IBuildEngine4 { private StringBuilder _log = new StringBuilder(); public MessageImportance MinimumMessageImportance = MessageImportance.Low; + private readonly Dictionary _registeredTaskObjects = new(); + public string Log { set { _log = new StringBuilder(value); } @@ -55,5 +58,40 @@ public void LogMessageEvent(BuildMessageEventArgs eventArgs) public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => throw new NotImplementedException(); + + // IBuildEngine2 + public bool IsRunningMultipleNodes => false; + + public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs, string toolsVersion) + => throw new NotImplementedException(); + + public bool BuildProjectFilesInParallel(string[] projectFileNames, string[] targetNames, IDictionary[] globalProperties, IDictionary[] targetOutputsPerProject, string[] toolsVersion, bool useResultsCache, bool unloadProjectsOnCompletion) + => throw new NotImplementedException(); + + // IBuildEngine3 + public BuildEngineResult BuildProjectFilesInParallel(string[] projectFileNames, string[] targetNames, IDictionary[] globalProperties, IList[] removeGlobalProperties, string[] toolsVersion, bool returnTargetOutputs) + => throw new NotImplementedException(); + + public void Yield() { } + + public void Reacquire() { } + + // IBuildEngine4 + public void RegisterTaskObject(object key, object? obj, RegisteredTaskObjectLifetime lifetime, bool allowEarlyCollection) + => _registeredTaskObjects[key] = obj; + + public object? GetRegisteredTaskObject(object key, RegisteredTaskObjectLifetime lifetime) + => _registeredTaskObjects.TryGetValue(key, out var value) ? value : null; + + public object? UnregisterTaskObject(object key, RegisteredTaskObjectLifetime lifetime) + { + if (_registeredTaskObjects.TryGetValue(key, out var value)) + { + _registeredTaskObjects.Remove(key); + return value; + } + + return null; + } } }