diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitDataTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitDataTests.cs index 6eac1920d..921117c1c 100644 --- a/src/Microsoft.Build.Tasks.Git.UnitTests/GitDataTests.cs +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitDataTests.cs @@ -54,7 +54,7 @@ public void MinimalGitData() Assert.Equal("1111111111111111111111111111111111111111", repository.GetHeadCommitSha()); var warnings = new List<(string, object?[])>(); - var sourceRoots = GitOperations.GetSourceRoots(repository, remoteName: null, warnOnMissingCommit: true, (message, args) => warnings.Add((message, args))); + var sourceRoots = GitOperations.GetSourceRoots(repository, remoteName: null, warnOnMissingCommitOrUnsupportedUri: true, (message, args) => warnings.Add((message, args))); AssertEx.Equal(new[] { $@"'{repoDir.Path}{s}' SourceControl='git' RevisionId='1111111111111111111111111111111111111111' ScmRepositoryUrl='http://github.com/test-org/test-repo'", diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitOperationsTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitOperationsTests.cs index 115f5809c..7a9122d2c 100644 --- a/src/Microsoft.Build.Tasks.Git.UnitTests/GitOperationsTests.cs +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitOperationsTests.cs @@ -192,147 +192,33 @@ public void GetRepositoryUrl_InsteadOf() Assert.Empty(warnings); } - /// - /// Test scenario where a local repository is cloned from another local repository that was cloned from a remote repository. - /// [Theory] - [InlineData(true)] - [InlineData(false)] - public void GetRepositoryUrl_Local(bool useFileUrl) + [InlineData("local")] + [InlineData("file")] + [InlineData("xyz://a/b")] + public void GetRepositoryUrl_UnsupportedUrl(string kind) { using var temp = new TempRoot(); var dir = temp.CreateDirectory(); - var mainWorkingDir = dir.CreateDirectory("x \u1234"); - var mainGitDir = mainWorkingDir.CreateDirectory(".git"); - mainGitDir.CreateFile("HEAD"); - mainGitDir.CreateFile("config").WriteAllText(@"[remote ""origin""] url = http://github.com/repo"); + var originRepoPath = dir.CreateDirectory("x \u1234").Path; - var repo = CreateRepository(config: new GitConfig(ImmutableDictionary.CreateRange(new[] - { - KVP(new GitVariableName("remote", "origin", "url"), - ImmutableArray.Create(useFileUrl ? new Uri(mainWorkingDir.Path).AbsolutePath : mainWorkingDir.Path)), - }))); - - var warnings = new List<(string, object?[])>(); - Assert.Equal("http://github.com/repo", GitOperations.GetRepositoryUrl(repo, remoteName: null, logWarning: (message, args) => warnings.Add((message, args)))); - Assert.Empty(warnings); - } - - /// - /// Test scenario where a local repository is cloned from another local repository that was cloned from a remote repository. - /// With custom remote name. - /// - [Fact] - public void GetRepositoryUrl_Local_CustomRemoteName() - { - using var temp = new TempRoot(); - - var mainWorkingDir = temp.CreateDirectory(); - var mainGitDir = mainWorkingDir.CreateDirectory(".git"); - mainGitDir.CreateFile("HEAD"); - mainGitDir.CreateFile("config").WriteAllText(@"[remote ""origin""] url = http://github.com/repo"); - - var repo = CreateRepository(config: new GitConfig(ImmutableDictionary.CreateRange(new[] - { - KVP(new GitVariableName("remote", "origin", "url"), ImmutableArray.Create(mainWorkingDir.Path)), - }))); - - var warnings = new List<(string, object?[])>(); - Assert.Equal("http://github.com/repo", GitOperations.GetRepositoryUrl(repo, remoteName: "myremote", logWarning: (message, args) => warnings.Add((message, args)))); - AssertEx.Equal(new[] { string.Format(Resources.RepositoryDoesNotHaveSpecifiedRemote, repo.WorkingDirectory, "myremote", "origin") }, warnings.Select(TestUtilities.InspectDiagnostic)); - } - - /// - /// Test scenario where a local repository is cloned from another local repository that does not have remote URL specified. - /// - [Fact] - public void GetRepositoryUrl_Local_NoRemoteOriginUrl() - { - using var temp = new TempRoot(); - - var mainWorkingDir = temp.CreateDirectory(); - var mainGitDir = mainWorkingDir.CreateDirectory(".git"); - mainGitDir.CreateFile("HEAD"); - - var repo = CreateRepository(config: new GitConfig(ImmutableDictionary.CreateRange(new[] + var url = kind switch { - KVP(new GitVariableName("remote", "origin", "url"), ImmutableArray.Create(mainWorkingDir.Path)), - }))); - - var warnings = new List<(string, object?[])>(); - Assert.Equal(new Uri(mainWorkingDir.Path).AbsoluteUri, GitOperations.GetRepositoryUrl(repo, remoteName: null, logWarning: (message, args) => warnings.Add((message, args)))); - AssertEx.Equal(new[] { string.Format(Resources.RepositoryHasNoRemote, mainWorkingDir.Path) }, warnings.Select(TestUtilities.InspectDiagnostic)); - } - - /// - /// Test scenario where a local repository is cloned from another local repository that does not have a working directory. - /// - [Fact] - public void GetRepositoryUrl_Local_NoWorkingDirectory() - { - using var temp = new TempRoot(); - - var dir = temp.CreateDirectory(); - - var gitDir1 = dir.CreateDirectory("1"); - gitDir1.CreateFile("HEAD"); - gitDir1.CreateFile("config").WriteAllText(@"[remote ""origin""] url = http://github.com/repo"); - - var gitDir2 = dir.CreateDirectory("2"); - gitDir2.CreateFile("HEAD"); - gitDir2.CreateFile("config").WriteAllText(@"[remote ""origin""] url = ../1"); - - var repo = CreateRepository(config: new GitConfig(ImmutableDictionary.CreateRange(new[] - { - KVP(new GitVariableName("remote", "origin", "url"), ImmutableArray.Create(gitDir2.Path)), - }))); - - var warnings = new List<(string, object?[])>(); - Assert.Null(GitOperations.GetRepositoryUrl(repo, remoteName: null, logWarning: (message, args) => warnings.Add((message, args)))); - AssertEx.Equal(new[] { string.Format(Resources.UnableToLocateRepository, gitDir2.Path) }, warnings.Select(TestUtilities.InspectDiagnostic)); - } - - /// - /// Test scenario where a local repository is cloned from another local repository that was cloned from a remote repository. - /// - [Fact] - public void GetRepositoryUrl_Local_BadRepo() - { - using var temp = new TempRoot(); - - var mainWorkingDir = temp.CreateDirectory(); - var mainGitDir = mainWorkingDir.CreateDirectory(".git"); - - var repo = CreateRepository(config: new GitConfig(ImmutableDictionary.CreateRange(new[] - { - KVP(new GitVariableName("remote", "origin", "url"), ImmutableArray.Create(mainWorkingDir.Path)), - }))); - - var warnings = new List<(string, object?[])>(); - Assert.Equal(new Uri(mainWorkingDir.Path).AbsoluteUri, GitOperations.GetRepositoryUrl(repo, remoteName: null, logWarning: (message, args) => warnings.Add((message, args)))); - AssertEx.Equal(new[] { string.Format(Resources.RepositoryHasNoRemote, mainWorkingDir.Path) }, warnings.Select(TestUtilities.InspectDiagnostic)); - } - - [Fact] - public void GetRepositoryUrl_LocalRecursion() - { - using var temp = new TempRoot(); - - var mainWorkingDir = temp.CreateDirectory(); - var mainGitDir = mainWorkingDir.CreateDirectory(".git"); - mainGitDir.CreateFile("HEAD"); - mainGitDir.CreateFile("config").WriteAllText($@"[remote ""origin""] url = {mainWorkingDir.Path.Replace('\\', '/')}"); + "local" => originRepoPath, + "file" => new Uri(originRepoPath).AbsolutePath, + _ => kind + }; var repo = CreateRepository(config: new GitConfig(ImmutableDictionary.CreateRange(new[] { - KVP(new GitVariableName("remote", "origin", "url"), - ImmutableArray.Create(mainWorkingDir.Path)), + KVP(new GitVariableName("remote", "origin", "url"), ImmutableArray.Create(url)), }))); var warnings = new List<(string, object?[])>(); - Assert.Equal(new Uri(mainWorkingDir.Path).AbsoluteUri, GitOperations.GetRepositoryUrl(repo, remoteName: null, logWarning: (message, args) => warnings.Add((message, args)))); - AssertEx.Equal(new[] { string.Format(Resources.RepositoryUrlEvaluationExceededMaximumAllowedDepth, "10") }, warnings.Select(TestUtilities.InspectDiagnostic)); + var uri = GitOperations.GetRepositoryUrl(repo, remoteName: null, warnOnMissingOrUnsupportedRemote: true, logWarning: (message, args) => warnings.Add((message, args))); + Assert.Null(uri); + AssertEx.Equal(new[] { string.Format(Resources.InvalidRepositoryRemoteUrl, "origin", url) }, warnings.Select(TestUtilities.InspectDiagnostic)); } [Theory] @@ -468,7 +354,7 @@ public void GetSourceRoots_RepoWithCommits_WithoutUrl() commitSha: "0000000000000000000000000000000000000000"); var warnings = new List<(string, object?[])>(); - var items = GitOperations.GetSourceRoots(repo, remoteName: null, warnOnMissingCommit: true, (message, args) => warnings.Add((message, args))); + var items = GitOperations.GetSourceRoots(repo, remoteName: null, warnOnMissingCommitOrUnsupportedUri: true, (message, args) => warnings.Add((message, args))); AssertEx.Equal(new[] { @@ -487,7 +373,7 @@ public void GetSourceRoots_RepoWithCommits_WithUrl() ("remote.origin.url", "http://github.com/abc"))); var warnings = new List<(string, object?[])>(); - var items = GitOperations.GetSourceRoots(repo, remoteName: null, warnOnMissingCommit: true, (message, args) => warnings.Add((message, args))); + var items = GitOperations.GetSourceRoots(repo, remoteName: null, warnOnMissingCommitOrUnsupportedUri: true, (message, args) => warnings.Add((message, args))); AssertEx.Equal(new[] { @@ -517,7 +403,7 @@ public void GetSourceRoots_RepoWithoutCommitsWithSubmodules() CreateSubmodule("sub6", "sub/6", "", "6666666666666666666666666666666666666666"))); var warnings = new List<(string, object?[])>(); - var items = GitOperations.GetSourceRoots(repo, remoteName: null, warnOnMissingCommit: false, (message, args) => warnings.Add((message, args))); + var items = GitOperations.GetSourceRoots(repo, remoteName: null, warnOnMissingCommitOrUnsupportedUri: false, (message, args) => warnings.Add((message, args))); // Module without a configuration entry is not initialized. // URLs listed in .submodules are ignored (they are used by git submodule initialize to generate URLs stored in config). @@ -567,8 +453,9 @@ public void GetSourceRoots_RepoWithCommitsWithSubmodules(bool warnOnMissingCommi } } - [Fact] - public void GetSourceRoots_RelativeSubmodulePath() + [Theory] + [CombinatorialData] + public void GetSourceRoots_RelativeSubmodulePath(bool warnOnMissingCommitOrUnsupportedUri) { using var temp = new TempRoot(); @@ -586,20 +473,32 @@ public void GetSourceRoots_RelativeSubmodulePath() workingDir: repoDir.Path, commitSha: "0000000000000000000000000000000000000000", config: CreateConfig( - ("submodule.1.url", "../1")), + ("submodule.1.url", "../1"), + ("submodule.2.url", "xyz://a/b")), submodules: ImmutableArray.Create( - CreateSubmodule("1", "sub/1", "---", "1111111111111111111111111111111111111111", containingRepositoryWorkingDir: repoDir.Path))); + CreateSubmodule("1", "sub/1", "---", "1111111111111111111111111111111111111111", containingRepositoryWorkingDir: repoDir.Path), + CreateSubmodule("2", "sub/2", "---", "2222222222222222222222222222222222222222", containingRepositoryWorkingDir: repoDir.Path))); var warnings = new List<(string, object?[])>(); - var items = GitOperations.GetSourceRoots(repo, remoteName: null, warnOnMissingCommit: false, (message, args) => warnings.Add((message, args))); + var items = GitOperations.GetSourceRoots(repo, remoteName: null, warnOnMissingCommitOrUnsupportedUri, (message, args) => warnings.Add((message, args))); AssertEx.Equal(new[] { - $@"'{repoDir.Path}{s}' SourceControl='git' RevisionId='0000000000000000000000000000000000000000'", - $@"'{repoDir.Path}{s}sub{s}1{s}' SourceControl='git' RevisionId='1111111111111111111111111111111111111111' NestedRoot='sub/1/' ContainingRoot='{repoDir.Path}{s}' ScmRepositoryUrl='http://github.com/repo1'", + $@"'{repoDir.Path}{s}' SourceControl='git' RevisionId='0000000000000000000000000000000000000000'" }, items.Select(TestUtilities.InspectSourceRoot)); - Assert.Empty(warnings); + if (warnOnMissingCommitOrUnsupportedUri) + { + AssertEx.Equal(new[] + { + string.Format(Resources.SourceCodeWontBeAvailableViaSourceLink, string.Format(Resources.InvalidSubmoduleUrl, "1", "../1")), + string.Format(Resources.SourceCodeWontBeAvailableViaSourceLink, string.Format(Resources.InvalidSubmoduleUrl, "2", "xyz://a/b")) + }, warnings.Select(TestUtilities.InspectDiagnostic)); + } + else + { + Assert.Empty(warnings); + } } private static GitOperations.DirectoryNode CreateNode(string name, string? submoduleWorkingDirectory, List? children = null) diff --git a/src/Microsoft.Build.Tasks.Git/GitOperations.cs b/src/Microsoft.Build.Tasks.Git/GitOperations.cs index 0fd5187c8..36fe13336 100644 --- a/src/Microsoft.Build.Tasks.Git/GitOperations.cs +++ b/src/Microsoft.Build.Tasks.Git/GitOperations.cs @@ -16,8 +16,6 @@ namespace Microsoft.Build.Tasks.Git { internal static class GitOperations { - private const int RemoteRepositoryRecursionLimit = 10; - private const string SourceControlName = "git"; private const string RemoteSectionName = "remote"; private const string SubmoduleSectionName = "submodule"; @@ -25,14 +23,11 @@ internal static class GitOperations private const string UrlSectionName = "url"; private const string UrlVariableName = "url"; - public static string? GetRepositoryUrl(GitRepository repository, string? remoteName, bool warnOnMissingRemote = true, Action? logWarning = null) - => GetRepositoryUrl(repository, remoteName, recursionDepth: 0, warnOnMissingRemote, logWarning); - - private static string? GetRepositoryUrl(GitRepository repository, string? remoteName, int recursionDepth, bool warnOnMissingRemote, Action? logWarning) + public static string? GetRepositoryUrl(GitRepository repository, string? remoteName, bool warnOnMissingOrUnsupportedRemote = true, Action? logWarning = null) { NullableDebug.Assert(repository.WorkingDirectory != null); - var remoteUrl = GetRemoteUrl(repository, ref remoteName, warnOnMissingRemote, logWarning); + var remoteUrl = GetRemoteUrl(repository, ref remoteName, warnOnMissingOrUnsupportedRemote, logWarning); if (remoteUrl == null) { return null; @@ -45,7 +40,18 @@ internal static class GitOperations return null; } - return ResolveUrl(uri, repository.Environment, remoteName, recursionDepth, warnOnMissingRemote, logWarning); + if (!IsSupportedScheme(uri.Scheme)) + { + if (warnOnMissingOrUnsupportedRemote) + { + // TODO: Better message https://github.com/dotnet/sourcelink/issues/1149 + logWarning?.Invoke(Resources.InvalidRepositoryRemoteUrl, new[] { remoteName, remoteUrl }); + } + + return null; + } + + return uri.AbsoluteUri; } private static string? GetRemoteUrl(GitRepository repository, ref string? remoteName, bool warnOnMissingRemote, Action? logWarning) @@ -79,40 +85,6 @@ internal static class GitOperations return remoteUrl; } - private static string? ResolveUrl(Uri uri, GitEnvironment environment, string? remoteName, int recursionDepth, bool warnOnMissingRemote, Action? logWarning) - { - if (!uri.IsFile) - { - return uri.AbsoluteUri; - } - - var repositoryPath = uri.LocalPath; - if (!GitRepository.TryGetRepositoryLocation(repositoryPath, out var remoteRepositoryLocation)) - { - if (warnOnMissingRemote) - { - logWarning?.Invoke(Resources.RepositoryHasNoRemote, new[] { repositoryPath }); - } - - return uri.AbsoluteUri; - } - - if (recursionDepth > RemoteRepositoryRecursionLimit) - { - logWarning?.Invoke(Resources.RepositoryUrlEvaluationExceededMaximumAllowedDepth, new[] { RemoteRepositoryRecursionLimit.ToString() }); - return null; - } - - var remoteRepository = GitRepository.OpenRepository(remoteRepositoryLocation, environment); - if (remoteRepository.WorkingDirectory == null) - { - logWarning?.Invoke(Resources.UnableToLocateRepository, new[] { repositoryPath }); - return null; - } - - return GetRepositoryUrl(remoteRepository, remoteName, recursionDepth + 1, warnOnMissingRemote, logWarning) ?? uri.AbsoluteUri; - } - private static bool TryGetRemote(GitConfig config, [NotNullWhen(true)]out string? remoteName, [NotNullWhen(true)]out string? remoteUrl) { remoteName = RemoteOriginName; @@ -177,6 +149,21 @@ internal static string ApplyInsteadOfUrlMapping(GitConfig config, string url) return NormalizeUrl(ApplyInsteadOfUrlMapping(repository.Config, url), root: repository.WorkingDirectory); } + /// + /// Git supports "http(s)", "ssh", "git" and "file" schemes. "ftp" support is obsolete. The scheme is case-sensitive. + /// See https://git-scm.com/docs/git-clone#_git_urls. + /// + /// Source Link does not support "file" scheme since repositories hosted locally or on a network share + /// are usually bare and the actual source files are not available (a work tree is not available). + /// The debugger also does not support "file" URLs in Source Link. + /// + /// The scenario of a repository on a network share that is not bare is rare since pushing into such repository + /// is not allowed by default (see configuration "receive.denyCurrentBranch") and the work tree needs + /// to be kept up to date by a service. + /// + private static bool IsSupportedScheme(string scheme) + => scheme is "http" or "https" or "ssh" or "git"; + internal static Uri? NormalizeUrl(string url, string root) { // Since git supports scp-like syntax for SSH URLs we convert it here, @@ -247,7 +234,7 @@ private static bool TryParseScp(string value, [NotNullWhen(true)]out Uri? uri) return Uri.TryCreate(url, UriKind.Absolute, out uri); } - public static ITaskItem[] GetSourceRoots(GitRepository repository, string? remoteName, bool warnOnMissingCommit, Action logWarning) + public static ITaskItem[] GetSourceRoots(GitRepository repository, string? remoteName, bool warnOnMissingCommitOrUnsupportedUri, Action logWarning) { // Not supported for repositories without a working directory. NullableDebug.Assert(repository.WorkingDirectory != null); @@ -270,7 +257,7 @@ public static ITaskItem[] GetSourceRoots(GitRepository repository, string? remot item.SetMetadata(Names.SourceRoot.RevisionId, revisionId); result.Add(item); } - else if (warnOnMissingCommit) + else if (warnOnMissingCommitOrUnsupportedUri) { logWarning(Resources.RepositoryHasNoCommit, Array.Empty()); } @@ -280,7 +267,7 @@ public static ITaskItem[] GetSourceRoots(GitRepository repository, string? remot var commitSha = submodule.HeadCommitSha; if (commitSha == null) { - if (warnOnMissingCommit) + if (warnOnMissingCommitOrUnsupportedUri) { logWarning(Resources.SourceCodeWontBeAvailableViaSourceLink, new[] { string.Format(Resources.SubmoduleWithoutCommit, new[] { submodule.Name }) }); @@ -309,11 +296,14 @@ public static ITaskItem[] GetSourceRoots(GitRepository repository, string? remot continue; } - var submoduleUrl = ResolveUrl(submoduleUri, repository.Environment, remoteName, recursionDepth: 0, warnOnMissingRemote: true, logWarning); - if (submoduleUrl == null) + if (!IsSupportedScheme(submoduleUri.Scheme)) { - logWarning(Resources.SourceCodeWontBeAvailableViaSourceLink, - new[] { string.Format(Resources.InvalidSubmoduleUrl, submodule.Name, submoduleConfigUrl) }); + if (warnOnMissingCommitOrUnsupportedUri) + { + // TODO: Better message https://github.com/dotnet/sourcelink/issues/1149 + logWarning(Resources.SourceCodeWontBeAvailableViaSourceLink, + new[] { string.Format(Resources.InvalidSubmoduleUrl, submodule.Name, submoduleConfigUrl) }); + } continue; } @@ -324,7 +314,7 @@ public static ITaskItem[] GetSourceRoots(GitRepository repository, string? remot var item = new TaskItem(Evaluation.ProjectCollection.Escape(submodule.WorkingDirectoryFullPath.EndWithSeparator())); item.SetMetadata(Names.SourceRoot.SourceControl, SourceControlName); - item.SetMetadata(Names.SourceRoot.ScmRepositoryUrl, Evaluation.ProjectCollection.Escape(submoduleUrl)); + item.SetMetadata(Names.SourceRoot.ScmRepositoryUrl, Evaluation.ProjectCollection.Escape(submoduleUri.AbsoluteUri)); item.SetMetadata(Names.SourceRoot.RevisionId, commitSha); item.SetMetadata(Names.SourceRoot.ContainingRoot, Evaluation.ProjectCollection.Escape(repoRoot)); item.SetMetadata(Names.SourceRoot.NestedRoot, Evaluation.ProjectCollection.Escape(submodule.WorkingDirectoryRelativePath.EndWithSeparator('/'))); diff --git a/src/Microsoft.Build.Tasks.Git/LocateRepository.cs b/src/Microsoft.Build.Tasks.Git/LocateRepository.cs index 01384412f..2760ee3b6 100644 --- a/src/Microsoft.Build.Tasks.Git/LocateRepository.cs +++ b/src/Microsoft.Build.Tasks.Git/LocateRepository.cs @@ -53,8 +53,8 @@ private protected override void Execute(GitRepository repository) RepositoryId = repository.GitDirectory; WorkingDirectory = repository.WorkingDirectory; - Url = GitOperations.GetRepositoryUrl(repository, RemoteName, warnOnMissingRemote: !NoWarnOnMissingInfo, Log.LogWarning); - Roots = GitOperations.GetSourceRoots(repository, RemoteName, warnOnMissingCommit: !NoWarnOnMissingInfo, Log.LogWarning); + Url = GitOperations.GetRepositoryUrl(repository, RemoteName, warnOnMissingOrUnsupportedRemote: !NoWarnOnMissingInfo, Log.LogWarning); + Roots = GitOperations.GetSourceRoots(repository, RemoteName, warnOnMissingCommitOrUnsupportedUri: !NoWarnOnMissingInfo, Log.LogWarning); RevisionId = repository.GetHeadCommitSha(); } }