From aa6f416db0b8e28456c037b707ca210dfc16eb63 Mon Sep 17 00:00:00 2001 From: Artur Zegarek Date: Fri, 6 Mar 2026 01:36:34 +0100 Subject: [PATCH 1/8] Preserve .git suffix in repository names when generating SourceLink URLs Do not strip the `.git` suffix from repository names in GetSourceLinkUrlGitTask. Some repositories legitimately include `.git` as part of their name, and removing the suffix produces incorrect SourceLink URLs and 404 responses. This change keeps `.git` when it is part of the repository name. Additionally update the GitWeb provider to avoid appending `.git` when the suffix is already present, preventing URLs like `repo.git.git`. Add regression tests for repository names ending with `.git`. --- .../GitProvider/GetSourceLinkUrlGitTask.cs | 9 ++-- .../AzureDevOpsUrlParserHostedTests.cs | 4 ++ .../GetSourceLinkUrlTests.cs | 50 +++++++++++++++++++ src/SourceLink.GitWeb/GetSourceLinkUrl.cs | 10 +++- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/Common/GitProvider/GetSourceLinkUrlGitTask.cs b/src/Common/GitProvider/GetSourceLinkUrlGitTask.cs index 458f7662f..e0c5f7fa3 100644 --- a/src/Common/GitProvider/GetSourceLinkUrlGitTask.cs +++ b/src/Common/GitProvider/GetSourceLinkUrlGitTask.cs @@ -133,11 +133,12 @@ static bool IsHexDigit(char c) var relativeUrl = gitUri.GetPath().TrimEnd('/'); - // The URL may or may not end with '.git' (case-sensitive), but content URLs do not include '.git' suffix: - const string GitUrlSuffix = ".git"; - if (relativeUrl.EndsWith(GitUrlSuffix, StringComparison.Ordinal) && !relativeUrl.EndsWith("/" + GitUrlSuffix, StringComparison.Ordinal)) + // Only strip ".git" when it is a dedicated path segment ("/.git"). + // Do not strip ".git" from repository names (e.g. "repo.git"). + const string DotGitSegment = "/.git"; + if (relativeUrl.EndsWith(DotGitSegment, StringComparison.Ordinal)) { - relativeUrl = relativeUrl[..^GitUrlSuffix.Length]; + relativeUrl = relativeUrl[..^DotGitSegment.Length]; } SourceLinkUrl = BuildSourceLinkUrl(contentUri, gitUri, relativeUrl, revisionId, hostItem); diff --git a/src/SourceLink.AzureRepos.Git.UnitTests/AzureDevOpsUrlParserHostedTests.cs b/src/SourceLink.AzureRepos.Git.UnitTests/AzureDevOpsUrlParserHostedTests.cs index 2284ff1ff..30f918468 100644 --- a/src/SourceLink.AzureRepos.Git.UnitTests/AzureDevOpsUrlParserHostedTests.cs +++ b/src/SourceLink.AzureRepos.Git.UnitTests/AzureDevOpsUrlParserHostedTests.cs @@ -55,6 +55,10 @@ public void TryParseHostedHttp_Error(string host, string relativeUrl) [InlineData("contoso.com", "/account/project/_git/repo", "account/project", "repo")] [InlineData("contoso.com", "/account/project/_git/_full/repo", "account/project", "repo")] [InlineData("contoso.com", "/account/project/_git/_optimized/repo", "account/project", "repo")] + [InlineData("dev.azure.com", "/org/project/_git/repo.git", "org/project", "repo.git")] + [InlineData("dev.azure.com", "/org/project/_git/repo.git/", "org/project", "repo.git")] + [InlineData("account.visualstudio.com", "/DefaultCollection/project/_git/repo.git", "project", "repo.git")] + public void TryParseHostedHttp_Success(string host, string relativeUrl, string repositoryPath, string repositoryName) { Assert.True(AzureDevOpsUrlParser.TryParseHostedHttp(host, relativeUrl, out var actualRepositoryPath, out var actualRepositoryName)); diff --git a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs index 97d10d83f..c58973b11 100644 --- a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs +++ b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs @@ -285,5 +285,55 @@ public void VisualStudioHost_ImplicitHost_DeafultCollection_Project_Team(string AssertEx.AreEqual($"https://{domain}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); Assert.True(result); } + + [Fact] + public void DevAzureCom_RepositoryName_WithDotGit_IsNotIgnored() + { + var urlWithoutDotGit = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo"); + var urlWithDotGit = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); + + Assert.True( + !string.Equals(urlWithoutDotGit, urlWithDotGit, StringComparison.Ordinal), + $"Repository name is ignored: URLs are identical.\n" + + $"Input without .git: https://dev.azure.com/org/project/_git/repo\n" + + $"Input with .git: https://dev.azure.com/org/project/_git/repo.git\n" + + $"Output: {urlWithoutDotGit}"); + } + + [Fact] + public void DevAzureCom_RepositoryName_WithDotGit_IsPreservedInOutput() + { + var url = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); + + Assert.True( + url.Contains("repo.git", StringComparison.Ordinal), + $"Repository suffix '.git' was not preserved in SourceLinkUrl.\nOutput: {url}"); + } + + private static string ExecuteDevAzureCom(string repositoryUrl) + { + var engine = new MockEngine(); + + var task = new GetSourceLinkUrl() + { + BuildEngine = engine, + SourceRoot = new MockItem("/src/", + KVP("RepositoryUrl", repositoryUrl), + KVP("SourceControl", "git"), + KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] + { + new MockItem("dev.azure.com") + } + }; + + var result = task.Execute(); + + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + Assert.True(result); + Assert.False(string.IsNullOrEmpty(task.SourceLinkUrl)); + + return task.SourceLinkUrl; + } } } diff --git a/src/SourceLink.GitWeb/GetSourceLinkUrl.cs b/src/SourceLink.GitWeb/GetSourceLinkUrl.cs index fea2d2f76..f2f5ed7eb 100644 --- a/src/SourceLink.GitWeb/GetSourceLinkUrl.cs +++ b/src/SourceLink.GitWeb/GetSourceLinkUrl.cs @@ -24,14 +24,20 @@ protected override Uri GetDefaultContentUriFromHostUri(string authority, Uri git protected override string BuildSourceLinkUrl(Uri contentUri, Uri gitUri, string relativeUrl, string revisionId, ITaskItem? hostItem) { - var trimLeadingSlash = relativeUrl.TrimStart('/'); + var projectPath = relativeUrl.TrimStart('/'); var trimmedContentUrl = contentUri.ToString().TrimEnd('/', '\\'); + const string GitSuffix = ".git"; + if (!projectPath.EndsWith(GitSuffix, StringComparison.Ordinal)) + { + projectPath += GitSuffix; + } + // p = project/path // a = action // hb = SHA/revision // f = repo file path - var gitwebRawUrl = UriUtilities.Combine(trimmedContentUrl, $"?p={trimLeadingSlash}.git;a=blob_plain;hb={revisionId};f=*"); + var gitwebRawUrl = UriUtilities.Combine(trimmedContentUrl, $"?p={projectPath};a=blob_plain;hb={revisionId};f=*"); return gitwebRawUrl; } } From b8d19450670881af9b38a0f088561ce0021c9fea Mon Sep 17 00:00:00 2001 From: Eales <46241595+Eales@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:52:13 +0100 Subject: [PATCH 2/8] Update src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../GetSourceLinkUrlTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs index c58973b11..3e8cdfaad 100644 --- a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs +++ b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs @@ -305,9 +305,10 @@ public void DevAzureCom_RepositoryName_WithDotGit_IsPreservedInOutput() { var url = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); - Assert.True( - url.Contains("repo.git", StringComparison.Ordinal), - $"Repository suffix '.git' was not preserved in SourceLinkUrl.\nOutput: {url}"); + // Ensure the '.git' suffix is preserved in the repository segment of the URL, not just anywhere. + Assert.Contains( + "project/_apis/git/repositories/repo.git/items", + url); } private static string ExecuteDevAzureCom(string repositoryUrl) From 1f949b861faadbddc0e171c0ca2ba21f6a0c1e51 Mon Sep 17 00:00:00 2001 From: Eales <46241595+Eales@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:55:39 +0100 Subject: [PATCH 3/8] Update src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../GetSourceLinkUrlTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs index 3e8cdfaad..7e7a87fb6 100644 --- a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs +++ b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs @@ -324,8 +324,8 @@ private static string ExecuteDevAzureCom(string repositoryUrl) KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), Hosts = new[] { - new MockItem("dev.azure.com") - } + new MockItem("dev.azure.com") + } }; var result = task.Execute(); From eea3f2c6fa9a2c76c6387e5ade2c93e9e234574f Mon Sep 17 00:00:00 2001 From: Artur Zegarek Date: Fri, 6 Mar 2026 02:14:57 +0100 Subject: [PATCH 4/8] SourceLinkUrl is nullable, but the test asserts it is not null or empty before returning it. The null-forgiving operator is used here to satisfy the nullable analysis. --- .../GetSourceLinkUrlTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs index c58973b11..55cfbc002 100644 --- a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs +++ b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs @@ -333,7 +333,7 @@ private static string ExecuteDevAzureCom(string repositoryUrl) Assert.True(result); Assert.False(string.IsNullOrEmpty(task.SourceLinkUrl)); - return task.SourceLinkUrl; + return task.SourceLinkUrl!; } } } From 2599b2db8c969862c4eebb60a2f1293920c50ed8 Mon Sep 17 00:00:00 2001 From: Artur Zegarek Date: Fri, 6 Mar 2026 02:36:13 +0100 Subject: [PATCH 5/8] Revert change in GetSourceLinkUrlGitTask The previous change modified the shared Git SourceLink task to preserve the `.git` suffix in repository names. This introduced regressions in other providers (e.g. Bitbucket and Common tests). The fix will be moved to the Azure Repos provider where the exact repository name is required by the Azure DevOps API, instead of changing the shared behavior for all providers. --- .../GetSourceLinkUrlTests.cs | 489 ++++++++---------- .../GetSourceLinkUrl.cs | 2 +- src/SourceLink.GitWeb/GetSourceLinkUrl.cs | 10 +- 3 files changed, 215 insertions(+), 286 deletions(-) diff --git a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs index 32d19591d..fea54a63c 100644 --- a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs +++ b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs @@ -1,340 +1,275 @@ // 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; -using static TestUtilities.KeyValuePairUtils; -namespace Microsoft.SourceLink.AzureRepos.Git.UnitTests +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.Build.Tasks.SourceControl { - public class GetSourceLinkUrlTests + public abstract class GetSourceLinkUrlGitTask : Task { - [Fact] - public void EmptyHosts() - { - var engine = new MockEngine(); + private const string SourceControlName = "git"; + protected const string NotApplicableValue = "N/A"; + private const string ContentUrlMetadataName = "ContentUrl"; - var task = new GetSourceLinkUrl() - { - BuildEngine = engine, - SourceRoot = new MockItem("x", KVP("RepositoryUrl", "http://abc.com"), KVP("SourceControl", "git")), - }; + /// + /// Optional, but null is elimated when the task starts executing. + /// + public ITaskItem? SourceRoot { get; set; } - var result = task.Execute(); + /// + /// List of additional repository hosts for which the task produces SourceLink URLs. + /// Each item maps a domain of a repository host (stored in the item identity) to a URL of the server that provides source file content (stored in ContentUrl metadata). + /// ContentUrl is optional. + /// + public ITaskItem[]? Hosts { get; set; } - AssertEx.AssertEqualToleratingWhitespaceDifferences( - "ERROR : " + string.Format(CommonResources.AtLeastOneRepositoryHostIsRequired, "SourceLinkAzureReposGitHost", "AzureRepos.Git"), engine.Log); + public string? RepositoryUrl { get; set; } - Assert.False(result); - } + public bool IsSingleProvider { get; set; } - [Theory] - [InlineData("", "")] - [InlineData("", "/")] - [InlineData("/", "")] - [InlineData("/", "/")] - public void BuildSourceLinkUrl(string s1, string s2) - { - var engine = new MockEngine(); + [Output] + public string? SourceLinkUrl { get; set; } - var task = new GetSourceLinkUrl() - { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", "http://subdomain.contoso.com:100/account/project/_git/repo" + s1), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] - { - new MockItem("contoso.com", KVP("ContentUrl", "https://domain.com/x/y" + s2)), - } - }; + internal GetSourceLinkUrlGitTask() { } - var result = task.Execute(); - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual("https://domain.com/x/y/account/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); - Assert.True(result); - } + protected abstract string ProviderDisplayName { get; } + protected abstract string HostsItemGroupName { get; } + protected virtual bool SupportsImplicitHost => true; - [Theory] - [InlineData("account.visualstudio.com", "visualstudio.com")] - [InlineData("account.vsts.me", "vsts.me")] - [InlineData("contoso.com/account", "contoso.com")] - public void BadUrl(string domainAndAccount, string host) - { - var engine = new MockEngine(); + /// + /// Get the default content URL for given host and git URL. + /// + /// The host authority. + /// Remote or submodule URL translated by . + /// + /// Use the scheme. Some servers might not support https, so we can't default to https. + /// + protected virtual Uri GetDefaultContentUriFromHostUri(string authority, Uri gitUri) + => new($"{gitUri.Scheme}://{authority}", UriKind.Absolute); - var task = new GetSourceLinkUrl() - { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"http://{domainAndAccount}/_git"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] { new MockItem(host) } - }; + protected virtual Uri GetDefaultContentUriFromRepositoryUri(Uri repositoryUri) + => GetDefaultContentUriFromHostUri(repositoryUri.GetAuthority(), repositoryUri); - var result = task.Execute(); - - // ERROR : The value of SourceRoot.RepositoryUrl with identity '/src/' is invalid: 'http://account.visualstudio.com/_git'"" - AssertEx.AssertEqualToleratingWhitespaceDifferences( - "ERROR : " + string.Format(CommonResources.ValueOfWithIdentityIsInvalid, "SourceRoot.RepositoryUrl", "/src/", $"http://{domainAndAccount}/_git"), engine.Log); - - Assert.False(result); - } + protected abstract string? BuildSourceLinkUrl(Uri contentUrl, Uri gitUri, string relativeUrl, string revisionId, ITaskItem? hostItem); - [Theory] - [InlineData("account.visualstudio.com", "visualstudio.com")] - [InlineData("account.vsts.me", "vsts.me")] - [InlineData("contoso.com/account", "contoso.com")] - public void RepoOnly(string domainAndAccount, string host) + public override bool Execute() { - var engine = new MockEngine(); - - var task = new GetSourceLinkUrl() - { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"http://{domainAndAccount}/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] { new MockItem(host) } - }; - - var result = task.Execute(); - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual($"http://{domainAndAccount}/repo/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); - Assert.True(result); + ExecuteImpl(); + return !Log.HasLoggedErrors; } - [Theory] - [InlineData("account.visualstudio.com", "visualstudio.com")] - [InlineData("account.vsts.me", "vsts.me")] - [InlineData("contoso.com/account", "contoso.com")] - public void Project(string domainAndAccount, string host) + private void ExecuteImpl() { - var engine = new MockEngine(); - - var task = new GetSourceLinkUrl() + // Avoid errors when no SourceRoot is specified, _InitializeXyzGitSourceLinkUrl target will simply not update any SourceRoots. + if (SourceRoot == null) { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domainAndAccount}/project/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] { new MockItem(host) } - }; - - var result = task.Execute(); - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual($"https://{domainAndAccount}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); - Assert.True(result); - } + return; + } - [Theory] - [InlineData("account.visualstudio.com", "visualstudio.com")] - [InlineData("account.vsts.me", "vsts.me")] - public void Project_Team(string domainAndAccount, string host) - { - var engine = new MockEngine(); - - var task = new GetSourceLinkUrl() + // skip SourceRoot that already has SourceLinkUrl set, or its SourceControl is not "git": + if (!string.IsNullOrEmpty(SourceRoot.GetMetadata(Names.SourceRoot.SourceLinkUrl)) || + !string.Equals(SourceRoot.GetMetadata(Names.SourceRoot.SourceControl), SourceControlName, StringComparison.OrdinalIgnoreCase)) { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domainAndAccount}/project/team/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] { new MockItem(host) } - }; - - var result = task.Execute(); - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual($"https://{domainAndAccount}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); - Assert.True(result); - } + SourceLinkUrl = NotApplicableValue; + return; + } - [Fact] - public void Project_Team_NonVisualStudioHost() - { - var engine = new MockEngine(); - - var task = new GetSourceLinkUrl() + var gitUrl = SourceRoot.GetMetadata(Names.SourceRoot.RepositoryUrl); + if (string.IsNullOrEmpty(gitUrl)) { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", "http://contoso.com/account/project/team/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] { new MockItem("contoso.com") } - }; + SourceLinkUrl = NotApplicableValue; - var result = task.Execute(); - - // ERROR : The value of SourceRoot.RepositoryUrl with identity '/src/' is invalid: 'http://contoso.com/account/project/team/_git/repo'"" - AssertEx.AssertEqualToleratingWhitespaceDifferences( - "ERROR : " + string.Format(CommonResources.ValueOfWithIdentityIsInvalid, "SourceRoot.RepositoryUrl", "/src/", "http://contoso.com/account/project/team/_git/repo"), engine.Log); + // If SourceRoot has commit sha but not repository URL the source control info is available, + // but the remote for the repo has not been defined yet. We already reported missing remote in that case + // (unless suppressed). + if (string.IsNullOrEmpty(SourceRoot.GetMetadata(Names.SourceRoot.RevisionId))) + { + Log.LogWarning(CommonResources.UnableToDetermineRepositoryUrl); + } - Assert.False(result); - } + return; + } - [Theory] - [InlineData("account.visualstudio.com", "visualstudio.com")] - [InlineData("account.vsts.me", "vsts.me")] - public void VisualStudioHost_DefaultCollection(string domain, string host) - { - var engine = new MockEngine(); - - var task = new GetSourceLinkUrl() + if (!Uri.TryCreate(gitUrl, UriKind.Absolute, out var gitUri)) { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] { new MockItem(host) } - }; - - var result = task.Execute(); - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual($"https://{domain}/repo/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); - - Assert.True(result); - } + Log.LogError(CommonResources.ValueOfWithIdentityIsInvalid, Names.SourceRoot.RepositoryUrlFullName, SourceRoot.ItemSpec, gitUrl); + return; + } - [Theory] - [InlineData("account.visualstudio.com", "visualstudio.com")] - [InlineData("account.vsts.me", "vsts.me")] - public void VisualStudioHost_DefaultCollection_Project(string domain, string host) - { - var engine = new MockEngine(); - - var task = new GetSourceLinkUrl() + var mappings = GetUrlMappings(gitUri).ToArray(); + if (Log.HasLoggedErrors) { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/project/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] { new MockItem(host) } - }; - - var result = task.Execute(); - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual($"https://{domain}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); - Assert.True(result); - } + return; + } - [Theory] - [InlineData("account.visualstudio.com", "visualstudio.com")] - [InlineData("account.vsts.me", "vsts.me")] - public void VisualStudioHost_DefaultCollection_Project_Team(string domain, string host) - { - var engine = new MockEngine(); + if (mappings.Length == 0) + { + Log.LogError(CommonResources.AtLeastOneRepositoryHostIsRequired, HostsItemGroupName, ProviderDisplayName); + return; + } - var task = new GetSourceLinkUrl() + if (!TryGetMatchingContentUri(mappings, gitUri, out var contentUri, out var hostItem)) { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/project/team/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] { new MockItem(host) } - }; - - var result = task.Execute(); - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual($"https://{domain}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); - Assert.True(result); - } + SourceLinkUrl = NotApplicableValue; + return; + } - [Theory] - [InlineData("account.visualstudio.com")] - [InlineData("account.vsts.me")] - public void VisualStudioHost_ImplicitHost_DeafultCollection(string domain) - { - var engine = new MockEngine(); + static bool IsHexDigit(char c) + => c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; - var task = new GetSourceLinkUrl() + var revisionId = SourceRoot.GetMetadata(Names.SourceRoot.RevisionId); + if (revisionId == null || revisionId.Length != 40 || !revisionId.All(IsHexDigit)) { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - IsSingleProvider = true, - RepositoryUrl = $"https://{domain}/DefaultCollection/project/_git/repo" - }; - - var result = task.Execute(); - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual($"https://{domain}/repo/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); - Assert.True(result); - } + Log.LogError(CommonResources.ValueOfWithIdentityIsNotValidCommitHash, Names.SourceRoot.RevisionIdFullName, SourceRoot.ItemSpec, revisionId); + return; + } - [Theory] - [InlineData("account.visualstudio.com")] - [InlineData("account.vsts.me")] - public void VisualStudioHost_ImplicitHost_DeafultCollection_Project(string domain) - { - var engine = new MockEngine(); + var relativeUrl = gitUri.GetPath().TrimEnd('/'); - var task = new GetSourceLinkUrl() + // The URL may or may not end with '.git' (case-sensitive), but content URLs do not include '.git' suffix: + const string GitUrlSuffix = ".git"; + if (relativeUrl.EndsWith(GitUrlSuffix, StringComparison.Ordinal) && !relativeUrl.EndsWith("/" + GitUrlSuffix, StringComparison.Ordinal)) { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/project/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - IsSingleProvider = true, - RepositoryUrl = $"https://{domain}/DefaultCollection/project/_git/repo" - }; - - var result = task.Execute(); - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual($"https://{domain}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); - Assert.True(result); + relativeUrl = relativeUrl[..^GitUrlSuffix.Length]; + } + + SourceLinkUrl = BuildSourceLinkUrl(contentUri, gitUri, relativeUrl, revisionId, hostItem); } - [Theory] - [InlineData("account.visualstudio.com")] - [InlineData("account.vsts.me")] - public void VisualStudioHost_ImplicitHost_DeafultCollection_Project_Team(string domain) + private readonly struct UrlMapping { - var engine = new MockEngine(); + public readonly string Host; + public readonly ITaskItem? HostItem; + public readonly int Port; + public readonly Uri ContentUri; + public readonly bool HasDefaultContentUri; - var task = new GetSourceLinkUrl() + public UrlMapping(string host, ITaskItem? hostItem, int port, Uri contentUri, bool hasDefaultContentUri) { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/project/team/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - IsSingleProvider = true, - RepositoryUrl = $"https://{domain}/DefaultCollection/project/_git/repo" - }; - - var result = task.Execute(); - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - AssertEx.AreEqual($"https://{domain}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); - Assert.True(result); + NullableDebug.Assert(port >= -1); + NullableDebug.Assert(!string.IsNullOrEmpty(host)); + NullableDebug.Assert(contentUri != null); + + Host = host; + Port = port; + HostItem = hostItem; + ContentUri = contentUri; + HasDefaultContentUri = hasDefaultContentUri; + } } - [Fact] - public void DevAzureCom_RepositoryName_WithDotGit_IsNotIgnored() + private IEnumerable GetUrlMappings(Uri gitUri) { - var urlWithoutDotGit = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo"); - var urlWithDotGit = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); - - Assert.True( - !string.Equals(urlWithoutDotGit, urlWithDotGit, StringComparison.Ordinal), - $"Repository name is ignored: URLs are identical.\n" + - $"Input without .git: https://dev.azure.com/org/project/_git/repo\n" + - $"Input with .git: https://dev.azure.com/org/project/_git/repo.git\n" + - $"Output: {urlWithoutDotGit}"); - } + static bool IsValidContentUri(Uri uri) + => uri.GetHost() != "" && uri.Query == "" && uri.UserInfo == ""; - [Fact] - public void DevAzureCom_RepositoryName_WithDotGit_IsPreservedInOutput() - { - var url = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); + if (Hosts != null) + { + foreach (var item in Hosts) + { + var hostUrl = item.ItemSpec; + + if (!UriUtilities.TryParseAuthority(hostUrl, out var hostUri)) + { + Log.LogError(CommonResources.ValuePassedToTaskParameterNotValidDomainName, nameof(Hosts), item.ItemSpec); + continue; + } + + Uri? contentUri; + var contentUrl = item.GetMetadata(ContentUrlMetadataName); + var hasDefaultContentUri = string.IsNullOrEmpty(contentUrl); + if (hasDefaultContentUri) + { + contentUri = GetDefaultContentUriFromHostUri(hostUri.GetAuthority(), gitUri); + } + else if (!Uri.TryCreate(contentUrl, UriKind.Absolute, out contentUri) || !IsValidContentUri(contentUri)) + { + Log.LogError(CommonResources.ValuePassedToTaskParameterNotValidHostUri, nameof(Hosts), contentUrl); + continue; + } + + yield return new UrlMapping(hostUri.GetHost(), item, hostUri.Port, contentUri, hasDefaultContentUri); + } + } - // Ensure the '.git' suffix is preserved in the repository segment of the URL, not just anywhere. - Assert.Contains( - "project/_apis/git/repositories/repo.git/items", - url); + // Add implicit host last, so that matching prefers explicitly listed hosts over the implicit one. + if (SupportsImplicitHost && IsSingleProvider) + { + if (Uri.TryCreate(RepositoryUrl, UriKind.Absolute, out var uri)) + { + // If the URL is a local path the host will be empty. + var host = uri.GetHost(); + if (host != "") + { + yield return new UrlMapping(host, hostItem: null, uri.GetExplicitPort(), GetDefaultContentUriFromRepositoryUri(uri), hasDefaultContentUri: true); + } + else + { + Log.LogError(CommonResources.ValuePassedToTaskParameterNotValidHostUri, nameof(RepositoryUrl), RepositoryUrl); + } + } + else + { + Log.LogError(CommonResources.ValuePassedToTaskParameterNotValidUri, nameof(RepositoryUrl), RepositoryUrl); + } + } } - private static string ExecuteDevAzureCom(string repositoryUrl) + private static bool TryGetMatchingContentUri(UrlMapping[] mappings, Uri repoUri, [NotNullWhen(true)] out Uri? contentUri, out ITaskItem? hostItem) { - var engine = new MockEngine(); - - var task = new GetSourceLinkUrl() + UrlMapping? FindMatch(bool exactHost) { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", - KVP("RepositoryUrl", repositoryUrl), - KVP("SourceControl", "git"), - KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] + UrlMapping? candidate = null; + + foreach (var mapping in mappings) { - new MockItem("dev.azure.com") + if (exactHost && repoUri.GetHost().Equals(mapping.Host, StringComparison.OrdinalIgnoreCase) || + !exactHost && repoUri.GetHost().EndsWith("." + mapping.Host, StringComparison.OrdinalIgnoreCase)) + { + // Port matches exactly: + if (repoUri.Port == mapping.Port) + { + return mapping; + } + + // Port not specified: + if (candidate == null && mapping.Port == -1) + { + candidate = mapping; + } + } } - }; - var result = task.Execute(); + return candidate; + } - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - Assert.True(result); - Assert.False(string.IsNullOrEmpty(task.SourceLinkUrl)); + var result = FindMatch(exactHost: true) ?? FindMatch(exactHost: false); + if (result == null) + { + contentUri = null; + hostItem = null; + return false; + } + + var value = result.Value; + contentUri = value.ContentUri; + hostItem = value.HostItem; + + // If the mapping did not specify ContentUrl and did not specify port, + // use the port from the RepositoryUrl, if a non-default is specified. + if (value.HasDefaultContentUri && value.Port == -1 && !repoUri.IsDefaultPort && contentUri.Port != repoUri.Port) + { + contentUri = new Uri($"{contentUri.Scheme}://{contentUri.Host}:{repoUri.Port}{contentUri.PathAndQuery}"); + } - return task.SourceLinkUrl!; + return true; } } } diff --git a/src/SourceLink.AzureRepos.Git/GetSourceLinkUrl.cs b/src/SourceLink.AzureRepos.Git/GetSourceLinkUrl.cs index f029f8e6d..e0e41000a 100644 --- a/src/SourceLink.AzureRepos.Git/GetSourceLinkUrl.cs +++ b/src/SourceLink.AzureRepos.Git/GetSourceLinkUrl.cs @@ -32,7 +32,7 @@ protected override Uri GetDefaultContentUriFromRepositoryUri(Uri repositoryUri) protected override string? BuildSourceLinkUrl(Uri contentUri, Uri gitUri, string relativeUrl, string revisionId, ITaskItem? hostItem) { - if (!AzureDevOpsUrlParser.TryParseHostedHttp(gitUri.GetHost(), relativeUrl, out var projectPath, out var repositoryName)) + if (!AzureDevOpsUrlParser.TryParseHostedHttp(gitUri.GetHost(), gitUri.GetPath(), out var projectPath, out var repositoryName)) { Log.LogError(CommonResources.ValueOfWithIdentityIsInvalid, Names.SourceRoot.RepositoryUrlFullName, SourceRoot!.ItemSpec, gitUri); return null; diff --git a/src/SourceLink.GitWeb/GetSourceLinkUrl.cs b/src/SourceLink.GitWeb/GetSourceLinkUrl.cs index f2f5ed7eb..fea2d2f76 100644 --- a/src/SourceLink.GitWeb/GetSourceLinkUrl.cs +++ b/src/SourceLink.GitWeb/GetSourceLinkUrl.cs @@ -24,20 +24,14 @@ protected override Uri GetDefaultContentUriFromHostUri(string authority, Uri git protected override string BuildSourceLinkUrl(Uri contentUri, Uri gitUri, string relativeUrl, string revisionId, ITaskItem? hostItem) { - var projectPath = relativeUrl.TrimStart('/'); + var trimLeadingSlash = relativeUrl.TrimStart('/'); var trimmedContentUrl = contentUri.ToString().TrimEnd('/', '\\'); - const string GitSuffix = ".git"; - if (!projectPath.EndsWith(GitSuffix, StringComparison.Ordinal)) - { - projectPath += GitSuffix; - } - // p = project/path // a = action // hb = SHA/revision // f = repo file path - var gitwebRawUrl = UriUtilities.Combine(trimmedContentUrl, $"?p={projectPath};a=blob_plain;hb={revisionId};f=*"); + var gitwebRawUrl = UriUtilities.Combine(trimmedContentUrl, $"?p={trimLeadingSlash}.git;a=blob_plain;hb={revisionId};f=*"); return gitwebRawUrl; } } From ec9bf9b1352ab706216919d513cf9b4076b824a0 Mon Sep 17 00:00:00 2001 From: Eales <46241595+Eales@users.noreply.github.com> Date: Fri, 6 Mar 2026 02:49:40 +0100 Subject: [PATCH 6/8] Refactor Git URL handling in GetSourceLinkUrlGitTask --- src/Common/GitProvider/GetSourceLinkUrlGitTask.cs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Common/GitProvider/GetSourceLinkUrlGitTask.cs b/src/Common/GitProvider/GetSourceLinkUrlGitTask.cs index e0c5f7fa3..a2f1fa01d 100644 --- a/src/Common/GitProvider/GetSourceLinkUrlGitTask.cs +++ b/src/Common/GitProvider/GetSourceLinkUrlGitTask.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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. @@ -133,12 +133,11 @@ static bool IsHexDigit(char c) var relativeUrl = gitUri.GetPath().TrimEnd('/'); - // Only strip ".git" when it is a dedicated path segment ("/.git"). - // Do not strip ".git" from repository names (e.g. "repo.git"). - const string DotGitSegment = "/.git"; - if (relativeUrl.EndsWith(DotGitSegment, StringComparison.Ordinal)) + // The URL may or may not end with '.git' (case-sensitive), but content URLs do not include '.git' suffix: + const string GitUrlSuffix = ".git"; + if (relativeUrl.EndsWith(GitUrlSuffix, StringComparison.Ordinal) && !relativeUrl.EndsWith("/" + GitUrlSuffix, StringComparison.Ordinal)) { - relativeUrl = relativeUrl[..^DotGitSegment.Length]; + relativeUrl = relativeUrl[..^GitUrlSuffix.Length]; } SourceLinkUrl = BuildSourceLinkUrl(contentUri, gitUri, relativeUrl, revisionId, hostItem); @@ -223,7 +222,7 @@ static bool IsValidContentUri(Uri uri) } } - private static bool TryGetMatchingContentUri(UrlMapping[] mappings, Uri repoUri, [NotNullWhen(true)]out Uri? contentUri, out ITaskItem? hostItem) + private static bool TryGetMatchingContentUri(UrlMapping[] mappings, Uri repoUri, [NotNullWhen(true)] out Uri? contentUri, out ITaskItem? hostItem) { UrlMapping? FindMatch(bool exactHost) { From b25449e0aed4c318fccfc696b16b63a1369dfbb1 Mon Sep 17 00:00:00 2001 From: Artur Zegarek Date: Fri, 6 Mar 2026 08:09:26 +0100 Subject: [PATCH 7/8] Restore GetSourceLinkUrlTests and add tests for Azure Repos repositories ending with .git Ensure that repository names ending with ".git" are not treated the same as repositories without the suffix and that the suffix is preserved in the generated SourceLink URL. --- .../GetSourceLinkUrlTests.cs | 533 +++++++++++------- 1 file changed, 324 insertions(+), 209 deletions(-) diff --git a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs index fea54a63c..0d0d43717 100644 --- a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs +++ b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs @@ -1,275 +1,390 @@ // 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 System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; - -namespace Microsoft.Build.Tasks.SourceControl +using Microsoft.Build.Tasks.SourceControl; +using TestUtilities; +using Xunit; +using static TestUtilities.KeyValuePairUtils; + +namespace Microsoft.SourceLink.AzureRepos.Git.UnitTests { - public abstract class GetSourceLinkUrlGitTask : Task + public class GetSourceLinkUrlTests { - private const string SourceControlName = "git"; - protected const string NotApplicableValue = "N/A"; - private const string ContentUrlMetadataName = "ContentUrl"; + [Fact] + public void EmptyHosts() + { + var engine = new MockEngine(); - /// - /// Optional, but null is elimated when the task starts executing. - /// - public ITaskItem? SourceRoot { get; set; } + var task = new GetSourceLinkUrl() + { + BuildEngine = engine, + SourceRoot = new MockItem("x", KVP("RepositoryUrl", "http://abc.com"), KVP("SourceControl", "git")), + }; - /// - /// List of additional repository hosts for which the task produces SourceLink URLs. - /// Each item maps a domain of a repository host (stored in the item identity) to a URL of the server that provides source file content (stored in ContentUrl metadata). - /// ContentUrl is optional. - /// - public ITaskItem[]? Hosts { get; set; } + var result = task.Execute(); - public string? RepositoryUrl { get; set; } + AssertEx.AssertEqualToleratingWhitespaceDifferences( + "ERROR : " + string.Format(CommonResources.AtLeastOneRepositoryHostIsRequired, "SourceLinkAzureReposGitHost", "AzureRepos.Git"), engine.Log); - public bool IsSingleProvider { get; set; } + Assert.False(result); + } - [Output] - public string? SourceLinkUrl { get; set; } + [Theory] + [InlineData("", "")] + [InlineData("", "/")] + [InlineData("/", "")] + [InlineData("/", "/")] + public void BuildSourceLinkUrl(string s1, string s2) + { + var engine = new MockEngine(); - internal GetSourceLinkUrlGitTask() { } + var task = new GetSourceLinkUrl() + { + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", "http://subdomain.contoso.com:100/account/project/_git/repo" + s1), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] + { + new MockItem("contoso.com", KVP("ContentUrl", "https://domain.com/x/y" + s2)), + } + }; - protected abstract string ProviderDisplayName { get; } - protected abstract string HostsItemGroupName { get; } - protected virtual bool SupportsImplicitHost => true; + var result = task.Execute(); + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + AssertEx.AreEqual("https://domain.com/x/y/account/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); + Assert.True(result); + } + + [Theory] + [InlineData("account.visualstudio.com", "visualstudio.com")] + [InlineData("account.vsts.me", "vsts.me")] + [InlineData("contoso.com/account", "contoso.com")] + public void BadUrl(string domainAndAccount, string host) + { + var engine = new MockEngine(); + + var task = new GetSourceLinkUrl() + { + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"http://{domainAndAccount}/_git"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] { new MockItem(host) } + }; - /// - /// Get the default content URL for given host and git URL. - /// - /// The host authority. - /// Remote or submodule URL translated by . - /// - /// Use the scheme. Some servers might not support https, so we can't default to https. - /// - protected virtual Uri GetDefaultContentUriFromHostUri(string authority, Uri gitUri) - => new($"{gitUri.Scheme}://{authority}", UriKind.Absolute); + var result = task.Execute(); - protected virtual Uri GetDefaultContentUriFromRepositoryUri(Uri repositoryUri) - => GetDefaultContentUriFromHostUri(repositoryUri.GetAuthority(), repositoryUri); + // ERROR : The value of SourceRoot.RepositoryUrl with identity '/src/' is invalid: 'http://account.visualstudio.com/_git'"" + AssertEx.AssertEqualToleratingWhitespaceDifferences( + "ERROR : " + string.Format(CommonResources.ValueOfWithIdentityIsInvalid, "SourceRoot.RepositoryUrl", "/src/", $"http://{domainAndAccount}/_git"), engine.Log); - protected abstract string? BuildSourceLinkUrl(Uri contentUrl, Uri gitUri, string relativeUrl, string revisionId, ITaskItem? hostItem); + Assert.False(result); + } - public override bool Execute() + [Theory] + [InlineData("account.visualstudio.com", "visualstudio.com")] + [InlineData("account.vsts.me", "vsts.me")] + [InlineData("contoso.com/account", "contoso.com")] + public void RepoOnly(string domainAndAccount, string host) { - ExecuteImpl(); - return !Log.HasLoggedErrors; + var engine = new MockEngine(); + + var task = new GetSourceLinkUrl() + { + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"http://{domainAndAccount}/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] { new MockItem(host) } + }; + + var result = task.Execute(); + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + AssertEx.AreEqual($"http://{domainAndAccount}/repo/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); + Assert.True(result); } - private void ExecuteImpl() + [Theory] + [InlineData("account.visualstudio.com", "visualstudio.com")] + [InlineData("account.vsts.me", "vsts.me")] + [InlineData("contoso.com/account", "contoso.com")] + public void Project(string domainAndAccount, string host) { - // Avoid errors when no SourceRoot is specified, _InitializeXyzGitSourceLinkUrl target will simply not update any SourceRoots. - if (SourceRoot == null) + var engine = new MockEngine(); + + var task = new GetSourceLinkUrl() { - return; - } + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domainAndAccount}/project/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] { new MockItem(host) } + }; + + var result = task.Execute(); + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + AssertEx.AreEqual($"https://{domainAndAccount}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); + Assert.True(result); + } - // skip SourceRoot that already has SourceLinkUrl set, or its SourceControl is not "git": - if (!string.IsNullOrEmpty(SourceRoot.GetMetadata(Names.SourceRoot.SourceLinkUrl)) || - !string.Equals(SourceRoot.GetMetadata(Names.SourceRoot.SourceControl), SourceControlName, StringComparison.OrdinalIgnoreCase)) + [Theory] + [InlineData("account.visualstudio.com", "visualstudio.com")] + [InlineData("account.vsts.me", "vsts.me")] + public void Project_Team(string domainAndAccount, string host) + { + var engine = new MockEngine(); + + var task = new GetSourceLinkUrl() { - SourceLinkUrl = NotApplicableValue; - return; - } + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domainAndAccount}/project/team/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] { new MockItem(host) } + }; + + var result = task.Execute(); + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + AssertEx.AreEqual($"https://{domainAndAccount}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); + Assert.True(result); + } - var gitUrl = SourceRoot.GetMetadata(Names.SourceRoot.RepositoryUrl); - if (string.IsNullOrEmpty(gitUrl)) + [Fact] + public void Project_Team_NonVisualStudioHost() + { + var engine = new MockEngine(); + + var task = new GetSourceLinkUrl() { - SourceLinkUrl = NotApplicableValue; + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", "http://contoso.com/account/project/team/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] { new MockItem("contoso.com") } + }; - // If SourceRoot has commit sha but not repository URL the source control info is available, - // but the remote for the repo has not been defined yet. We already reported missing remote in that case - // (unless suppressed). - if (string.IsNullOrEmpty(SourceRoot.GetMetadata(Names.SourceRoot.RevisionId))) - { - Log.LogWarning(CommonResources.UnableToDetermineRepositoryUrl); - } + var result = task.Execute(); - return; - } + // ERROR : The value of SourceRoot.RepositoryUrl with identity '/src/' is invalid: 'http://contoso.com/account/project/team/_git/repo'"" + AssertEx.AssertEqualToleratingWhitespaceDifferences( + "ERROR : " + string.Format(CommonResources.ValueOfWithIdentityIsInvalid, "SourceRoot.RepositoryUrl", "/src/", "http://contoso.com/account/project/team/_git/repo"), engine.Log); - if (!Uri.TryCreate(gitUrl, UriKind.Absolute, out var gitUri)) + Assert.False(result); + } + + [Theory] + [InlineData("account.visualstudio.com", "visualstudio.com")] + [InlineData("account.vsts.me", "vsts.me")] + public void VisualStudioHost_DefaultCollection(string domain, string host) + { + var engine = new MockEngine(); + + var task = new GetSourceLinkUrl() { - Log.LogError(CommonResources.ValueOfWithIdentityIsInvalid, Names.SourceRoot.RepositoryUrlFullName, SourceRoot.ItemSpec, gitUrl); - return; - } + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] { new MockItem(host) } + }; + + var result = task.Execute(); + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + AssertEx.AreEqual($"https://{domain}/repo/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); + + Assert.True(result); + } + + [Theory] + [InlineData("account.visualstudio.com", "visualstudio.com")] + [InlineData("account.vsts.me", "vsts.me")] + public void VisualStudioHost_DefaultCollection_Project(string domain, string host) + { + var engine = new MockEngine(); - var mappings = GetUrlMappings(gitUri).ToArray(); - if (Log.HasLoggedErrors) + var task = new GetSourceLinkUrl() { - return; - } + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/project/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] { new MockItem(host) } + }; + + var result = task.Execute(); + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + AssertEx.AreEqual($"https://{domain}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); + Assert.True(result); + } - if (mappings.Length == 0) + [Theory] + [InlineData("account.visualstudio.com", "visualstudio.com")] + [InlineData("account.vsts.me", "vsts.me")] + public void VisualStudioHost_DefaultCollection_Project_Team(string domain, string host) + { + var engine = new MockEngine(); + + var task = new GetSourceLinkUrl() { - Log.LogError(CommonResources.AtLeastOneRepositoryHostIsRequired, HostsItemGroupName, ProviderDisplayName); - return; - } + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/project/team/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] { new MockItem(host) } + }; + + var result = task.Execute(); + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + AssertEx.AreEqual($"https://{domain}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); + Assert.True(result); + } - if (!TryGetMatchingContentUri(mappings, gitUri, out var contentUri, out var hostItem)) + [Theory] + [InlineData("account.visualstudio.com")] + [InlineData("account.vsts.me")] + public void VisualStudioHost_ImplicitHost_DeafultCollection(string domain) + { + var engine = new MockEngine(); + + var task = new GetSourceLinkUrl() { - SourceLinkUrl = NotApplicableValue; - return; - } + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + IsSingleProvider = true, + RepositoryUrl = $"https://{domain}/DefaultCollection/project/_git/repo" + }; + + var result = task.Execute(); + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + AssertEx.AreEqual($"https://{domain}/repo/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); + Assert.True(result); + } - static bool IsHexDigit(char c) - => c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; + [Theory] + [InlineData("account.visualstudio.com")] + [InlineData("account.vsts.me")] + public void VisualStudioHost_ImplicitHost_DeafultCollection_Project(string domain) + { + var engine = new MockEngine(); - var revisionId = SourceRoot.GetMetadata(Names.SourceRoot.RevisionId); - if (revisionId == null || revisionId.Length != 40 || !revisionId.All(IsHexDigit)) + var task = new GetSourceLinkUrl() { - Log.LogError(CommonResources.ValueOfWithIdentityIsNotValidCommitHash, Names.SourceRoot.RevisionIdFullName, SourceRoot.ItemSpec, revisionId); - return; - } + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/project/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + IsSingleProvider = true, + RepositoryUrl = $"https://{domain}/DefaultCollection/project/_git/repo" + }; + + var result = task.Execute(); + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + AssertEx.AreEqual($"https://{domain}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); + Assert.True(result); + } - var relativeUrl = gitUri.GetPath().TrimEnd('/'); + [Theory] + [InlineData("account.visualstudio.com")] + [InlineData("account.vsts.me")] + public void VisualStudioHost_ImplicitHost_DeafultCollection_Project_Team(string domain) + { + var engine = new MockEngine(); - // The URL may or may not end with '.git' (case-sensitive), but content URLs do not include '.git' suffix: - const string GitUrlSuffix = ".git"; - if (relativeUrl.EndsWith(GitUrlSuffix, StringComparison.Ordinal) && !relativeUrl.EndsWith("/" + GitUrlSuffix, StringComparison.Ordinal)) + var task = new GetSourceLinkUrl() { - relativeUrl = relativeUrl[..^GitUrlSuffix.Length]; - } + BuildEngine = engine, + SourceRoot = new MockItem("/src/", KVP("RepositoryUrl", $"https://{domain}/DefaultCollection/project/team/_git/repo"), KVP("SourceControl", "git"), KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + IsSingleProvider = true, + RepositoryUrl = $"https://{domain}/DefaultCollection/project/_git/repo" + }; + + var result = task.Execute(); + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + AssertEx.AreEqual($"https://{domain}/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=0123456789abcdefABCDEF000000000000000000&path=/*", task.SourceLinkUrl); + Assert.True(result); + } - SourceLinkUrl = BuildSourceLinkUrl(contentUri, gitUri, relativeUrl, revisionId, hostItem); + [Fact] + public void DevAzureCom_RepositoryName_WithDotGit_IsNotIgnored() + { + var urlWithoutDotGit = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo"); + var urlWithDotGit = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); + + Assert.True( + !string.Equals(urlWithoutDotGit, urlWithDotGit, StringComparison.Ordinal), + $"Repository name is ignored: URLs are identical.\n" + + $"Input without .git: https://dev.azure.com/org/project/_git/repo\n" + + $"Input with .git: https://dev.azure.com/org/project/_git/repo.git\n" + + $"Output: {urlWithoutDotGit}"); } - private readonly struct UrlMapping + [Fact] + public void DevAzureCom_RepositoryName_WithDotGit_IsPreservedInOutput() { - public readonly string Host; - public readonly ITaskItem? HostItem; - public readonly int Port; - public readonly Uri ContentUri; - public readonly bool HasDefaultContentUri; + var url = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); - public UrlMapping(string host, ITaskItem? hostItem, int port, Uri contentUri, bool hasDefaultContentUri) - { - NullableDebug.Assert(port >= -1); - NullableDebug.Assert(!string.IsNullOrEmpty(host)); - NullableDebug.Assert(contentUri != null); - - Host = host; - Port = port; - HostItem = hostItem; - ContentUri = contentUri; - HasDefaultContentUri = hasDefaultContentUri; - } + // Ensure the '.git' suffix is preserved in the repository segment of the URL, not just anywhere. + Assert.Contains( + "project/_apis/git/repositories/repo.git/items", + url); } - private IEnumerable GetUrlMappings(Uri gitUri) + private static string ExecuteDevAzureCom(string repositoryUrl) { - static bool IsValidContentUri(Uri uri) - => uri.GetHost() != "" && uri.Query == "" && uri.UserInfo == ""; + var engine = new MockEngine(); - if (Hosts != null) + var task = new GetSourceLinkUrl() { - foreach (var item in Hosts) + BuildEngine = engine, + SourceRoot = new MockItem("/src/", + KVP("RepositoryUrl", repositoryUrl), + KVP("SourceControl", "git"), + KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] { - var hostUrl = item.ItemSpec; - - if (!UriUtilities.TryParseAuthority(hostUrl, out var hostUri)) - { - Log.LogError(CommonResources.ValuePassedToTaskParameterNotValidDomainName, nameof(Hosts), item.ItemSpec); - continue; - } - - Uri? contentUri; - var contentUrl = item.GetMetadata(ContentUrlMetadataName); - var hasDefaultContentUri = string.IsNullOrEmpty(contentUrl); - if (hasDefaultContentUri) - { - contentUri = GetDefaultContentUriFromHostUri(hostUri.GetAuthority(), gitUri); - } - else if (!Uri.TryCreate(contentUrl, UriKind.Absolute, out contentUri) || !IsValidContentUri(contentUri)) - { - Log.LogError(CommonResources.ValuePassedToTaskParameterNotValidHostUri, nameof(Hosts), contentUrl); - continue; - } - - yield return new UrlMapping(hostUri.GetHost(), item, hostUri.Port, contentUri, hasDefaultContentUri); + new MockItem("dev.azure.com") } - } + }; - // Add implicit host last, so that matching prefers explicitly listed hosts over the implicit one. - if (SupportsImplicitHost && IsSingleProvider) - { - if (Uri.TryCreate(RepositoryUrl, UriKind.Absolute, out var uri)) - { - // If the URL is a local path the host will be empty. - var host = uri.GetHost(); - if (host != "") - { - yield return new UrlMapping(host, hostItem: null, uri.GetExplicitPort(), GetDefaultContentUriFromRepositoryUri(uri), hasDefaultContentUri: true); - } - else - { - Log.LogError(CommonResources.ValuePassedToTaskParameterNotValidHostUri, nameof(RepositoryUrl), RepositoryUrl); - } - } - else - { - Log.LogError(CommonResources.ValuePassedToTaskParameterNotValidUri, nameof(RepositoryUrl), RepositoryUrl); - } - } + var result = task.Execute(); + + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + Assert.True(result); + Assert.False(string.IsNullOrEmpty(task.SourceLinkUrl)); + + return task.SourceLinkUrl!; } - private static bool TryGetMatchingContentUri(UrlMapping[] mappings, Uri repoUri, [NotNullWhen(true)] out Uri? contentUri, out ITaskItem? hostItem) + [Fact] + public void DevAzureCom_RepositoryName_WithDotGit_IsNotIgnored() { - UrlMapping? FindMatch(bool exactHost) - { - UrlMapping? candidate = null; + var urlWithoutDotGit = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo"); + var urlWithDotGit = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); + + Assert.True( + !string.Equals(urlWithoutDotGit, urlWithDotGit, StringComparison.Ordinal), + $"Repository name is ignored: URLs are identical.\n" + + $"Input without .git: https://dev.azure.com/org/project/_git/repo\n" + + $"Input with .git: https://dev.azure.com/org/project/_git/repo.git\n" + + $"Output: {urlWithoutDotGit}"); + } - foreach (var mapping in mappings) - { - if (exactHost && repoUri.GetHost().Equals(mapping.Host, StringComparison.OrdinalIgnoreCase) || - !exactHost && repoUri.GetHost().EndsWith("." + mapping.Host, StringComparison.OrdinalIgnoreCase)) - { - // Port matches exactly: - if (repoUri.Port == mapping.Port) - { - return mapping; - } - - // Port not specified: - if (candidate == null && mapping.Port == -1) - { - candidate = mapping; - } - } - } + [Fact] + public void DevAzureCom_RepositoryName_WithDotGit_IsPreservedInOutput() + { + var url = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); - return candidate; - } + Assert.Contains( + "project/_apis/git/repositories/repo.git/items", + url); + } - var result = FindMatch(exactHost: true) ?? FindMatch(exactHost: false); - if (result == null) - { - contentUri = null; - hostItem = null; - return false; - } - - var value = result.Value; - contentUri = value.ContentUri; - hostItem = value.HostItem; - - // If the mapping did not specify ContentUrl and did not specify port, - // use the port from the RepositoryUrl, if a non-default is specified. - if (value.HasDefaultContentUri && value.Port == -1 && !repoUri.IsDefaultPort && contentUri.Port != repoUri.Port) + private static string ExecuteDevAzureCom(string repositoryUrl) + { + var engine = new MockEngine(); + + var task = new GetSourceLinkUrl() { - contentUri = new Uri($"{contentUri.Scheme}://{contentUri.Host}:{repoUri.Port}{contentUri.PathAndQuery}"); - } + BuildEngine = engine, + SourceRoot = new MockItem("/src/", + KVP("RepositoryUrl", repositoryUrl), + KVP("SourceControl", "git"), + KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), + Hosts = new[] + { + new MockItem("dev.azure.com") + } + }; + + var result = task.Execute(); + + AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); + Assert.True(result); + Assert.False(string.IsNullOrEmpty(task.SourceLinkUrl)); - return true; + return task.SourceLinkUrl!; } } } From 9990bd55339424918483fc14c4f0aa4d61a48507 Mon Sep 17 00:00:00 2001 From: Artur Zegarek Date: Fri, 6 Mar 2026 08:30:01 +0100 Subject: [PATCH 8/8] Remove duplicated Azure Repos .git regression tests Fix build failure caused by accidentally duplicated test methods introduced while adding regression tests for repository names ending with `.git`. --- .../GetSourceLinkUrlTests.cs | 52 ------------------- 1 file changed, 52 deletions(-) diff --git a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs index 0d0d43717..00011597b 100644 --- a/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs +++ b/src/SourceLink.AzureRepos.Git.UnitTests/GetSourceLinkUrlTests.cs @@ -72,7 +72,6 @@ public void BadUrl(string domainAndAccount, string host) var result = task.Execute(); - // ERROR : The value of SourceRoot.RepositoryUrl with identity '/src/' is invalid: 'http://account.visualstudio.com/_git'"" AssertEx.AssertEqualToleratingWhitespaceDifferences( "ERROR : " + string.Format(CommonResources.ValueOfWithIdentityIsInvalid, "SourceRoot.RepositoryUrl", "/src/", $"http://{domainAndAccount}/_git"), engine.Log); @@ -155,7 +154,6 @@ public void Project_Team_NonVisualStudioHost() var result = task.Execute(); - // ERROR : The value of SourceRoot.RepositoryUrl with identity '/src/' is invalid: 'http://contoso.com/account/project/team/_git/repo'"" AssertEx.AssertEqualToleratingWhitespaceDifferences( "ERROR : " + string.Format(CommonResources.ValueOfWithIdentityIsInvalid, "SourceRoot.RepositoryUrl", "/src/", "http://contoso.com/account/project/team/_git/repo"), engine.Log); @@ -336,55 +334,5 @@ private static string ExecuteDevAzureCom(string repositoryUrl) return task.SourceLinkUrl!; } - - [Fact] - public void DevAzureCom_RepositoryName_WithDotGit_IsNotIgnored() - { - var urlWithoutDotGit = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo"); - var urlWithDotGit = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); - - Assert.True( - !string.Equals(urlWithoutDotGit, urlWithDotGit, StringComparison.Ordinal), - $"Repository name is ignored: URLs are identical.\n" + - $"Input without .git: https://dev.azure.com/org/project/_git/repo\n" + - $"Input with .git: https://dev.azure.com/org/project/_git/repo.git\n" + - $"Output: {urlWithoutDotGit}"); - } - - [Fact] - public void DevAzureCom_RepositoryName_WithDotGit_IsPreservedInOutput() - { - var url = ExecuteDevAzureCom("https://dev.azure.com/org/project/_git/repo.git"); - - Assert.Contains( - "project/_apis/git/repositories/repo.git/items", - url); - } - - private static string ExecuteDevAzureCom(string repositoryUrl) - { - var engine = new MockEngine(); - - var task = new GetSourceLinkUrl() - { - BuildEngine = engine, - SourceRoot = new MockItem("/src/", - KVP("RepositoryUrl", repositoryUrl), - KVP("SourceControl", "git"), - KVP("RevisionId", "0123456789abcdefABCDEF000000000000000000")), - Hosts = new[] - { - new MockItem("dev.azure.com") - } - }; - - var result = task.Execute(); - - AssertEx.AssertEqualToleratingWhitespaceDifferences("", engine.Log); - Assert.True(result); - Assert.False(string.IsNullOrEmpty(task.SourceLinkUrl)); - - return task.SourceLinkUrl!; - } } }