From a2d18ee33b9651e014c19f87cf68d5985b0cf1e4 Mon Sep 17 00:00:00 2001 From: Rob Gibbens Date: Thu, 16 Apr 2026 14:16:08 -0400 Subject: [PATCH 1/4] Added git url validation for Azure DevOps repositories Azure DevOps repositories have a different format compared to GitHub urls. This update adds RegEx support for Azure DevOps repos. --- .../GitUrlUtilitiesTests.cs | 2 ++ src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs index 6214e32..307d21e 100644 --- a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs +++ b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs @@ -10,6 +10,8 @@ public class GitUrlUtilitiesTests [Theory] [InlineData("https://github.com/example/repo.git", true)] [InlineData("https://github.com/example/repo", false)] + [InlineData("https://example@dev.azure.com/example/example%20web%20site/_git/example%20web%20site", true)] + [InlineData("git@ssh.dev.azure.com:v3/example/example%20web%20site/example%20web%20site", true)] [InlineData("git@github.com:example/repo", false)] [InlineData("git://github.com/example/repo", false)] [InlineData("https://example.com", false)] diff --git a/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs b/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs index 2ff045f..d58b194 100644 --- a/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs +++ b/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs @@ -10,6 +10,6 @@ internal static string GetProjectNameFromGitUrl(string gitUrl) => internal static bool IsValidGitUrl(string url) => !string.IsNullOrEmpty(url) && GitUrlRegex().IsMatch(url); - [GeneratedRegex(@"^(?:git|https?|git@[\w\.]+):\/\/[\w\.@\:\/\-~]+\.git(?:\/)?$")] + [GeneratedRegex(@"^(?:(?:git|https?):\/\/[^\s]+\.git(?:\/)?|https:\/\/(?:[\w.-]+@)?dev\.azure\.com\/[^\/\s]+\/[^\/\s]+\/_git\/[^\/\s]+(?:\/)?|git@ssh\.dev\.azure\.com:v3\/[^\/\s]+\/[^\/\s]+\/[^\/\s]+(?:\/)?)$")] private static partial Regex GitUrlRegex(); } From 7c95272f78b17b58d3fa336a040e7fa2553b6a7d Mon Sep 17 00:00:00 2001 From: Rob Gibbens Date: Thu, 16 Apr 2026 14:22:31 -0400 Subject: [PATCH 2/4] Update src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../GitUrlUtilitiesTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs index 307d21e..f9b2e05 100644 --- a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs +++ b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs @@ -11,6 +11,8 @@ public class GitUrlUtilitiesTests [InlineData("https://github.com/example/repo.git", true)] [InlineData("https://github.com/example/repo", false)] [InlineData("https://example@dev.azure.com/example/example%20web%20site/_git/example%20web%20site", true)] + [InlineData("https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site", true)] + [InlineData("https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site/", true)] [InlineData("git@ssh.dev.azure.com:v3/example/example%20web%20site/example%20web%20site", true)] [InlineData("git@github.com:example/repo", false)] [InlineData("git://github.com/example/repo", false)] From b1a8d8e3ec4acf8398682973160c07d53cf88e7d Mon Sep 17 00:00:00 2001 From: Rob Gibbens Date: Thu, 16 Apr 2026 14:35:29 -0400 Subject: [PATCH 3/4] Added support for getting project name from Azure DevOps urls --- .../GitRepositoryConfigBuilderTests.cs | 16 +++++++++++++ .../GitUrlUtilitiesTests.cs | 23 +++++++++++++++++++ .../GitUrlUtilities.cs | 19 +++++++++++++-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitRepositoryConfigBuilderTests.cs b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitRepositoryConfigBuilderTests.cs index 2ae52f2..87ddd8f 100644 --- a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitRepositoryConfigBuilderTests.cs +++ b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitRepositoryConfigBuilderTests.cs @@ -65,6 +65,22 @@ public void BuildConfig_ShouldUseProvidedValues_WhenValuesAreProvided() config.FileSystem.Should().Be(fileSystem); } + [Fact] + public void BuildConfig_ShouldUseDecodedRepositoryName_ForAzureDevOpsUrls() + { + // Arrange + const string gitUrl = "https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site"; + + RepositoryConfigBuilder builder = new RepositoryConfigBuilder() + .WithGitUrl(gitUrl); + + // Act + RepositoryConfig config = builder.Build(); + + // Assert + config.RepositoryPath.Should().Be(Path.Combine(Path.GetFullPath("."), "example web site")); + } + [Fact] public void WithCloneTargetPath_ShouldSetCloneTargetPath() { diff --git a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs index f9b2e05..39c3798 100644 --- a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs +++ b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs @@ -6,6 +6,7 @@ public class GitUrlUtilitiesTests { private const string GitUrl = "https://github.com/example/repo.git"; private const string ExpectedProjectName = "repo"; + private const string AzureDevOpsGitUrl = "https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site"; [Theory] [InlineData("https://github.com/example/repo.git", true)] @@ -60,4 +61,26 @@ public void GetProjectNameFromGitUrl_ShouldReturnLastSegment_WhenUrlDoesNotEndWi // Assert projectName.Should().Be(ExpectedProjectName); } + + [Theory] + [InlineData(AzureDevOpsGitUrl)] + [InlineData("https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site/")] + public void GetProjectNameFromGitUrl_ShouldDecodePercentEncodedRepositoryNames(string gitUrl) + { + // Act + string projectName = GitUrlUtilities.GetProjectNameFromGitUrl(gitUrl); + + // Assert + projectName.Should().Be("example web site"); + } + + [Fact] + public void GetProjectNameFromGitUrl_ShouldThrow_WhenDecodedRepositoryNameContainsPathSeparators() + { + // Act + Action act = () => GitUrlUtilities.GetProjectNameFromGitUrl("https://dev.azure.com/example/project/_git/repository%2Fchild"); + + // Assert + act.Should().Throw(); + } } \ No newline at end of file diff --git a/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs b/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs index d58b194..8435a6c 100644 --- a/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs +++ b/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs @@ -4,8 +4,23 @@ namespace Dutchskull.Aspire.PolyRepo; internal static partial class GitUrlUtilities { - internal static string GetProjectNameFromGitUrl(string gitUrl) => - gitUrl.RemovePostfix(".git").Split('/')[^1]; + internal static string GetProjectNameFromGitUrl(string gitUrl) + { + string normalizedGitUrl = gitUrl.TrimEnd('/').RemovePostfix(".git"); + string encodedProjectName = normalizedGitUrl[(normalizedGitUrl.LastIndexOf('/') + 1)..]; + string projectName = Uri.UnescapeDataString(encodedProjectName); + + if (string.IsNullOrWhiteSpace(projectName) || + projectName is "." or ".." || + projectName.Contains('/') || + projectName.Contains('\\') || + projectName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new ArgumentException("Git URL resolved to an unsafe repository name.", nameof(gitUrl)); + } + + return projectName; + } internal static bool IsValidGitUrl(string url) => !string.IsNullOrEmpty(url) && GitUrlRegex().IsMatch(url); From a2418c2027dc928274a2ed29a59f673c0c4c69c1 Mon Sep 17 00:00:00 2001 From: Rob Gibbens Date: Thu, 16 Apr 2026 15:20:43 -0400 Subject: [PATCH 4/4] Optimizing GetProjectNameFromGitUrl method Optimizing hot path --- .../GitUrlUtilitiesTests.cs | 1 + .../GitUrlUtilities.cs | 21 ++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs index 39c3798..8f63808 100644 --- a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs +++ b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs @@ -65,6 +65,7 @@ public void GetProjectNameFromGitUrl_ShouldReturnLastSegment_WhenUrlDoesNotEndWi [Theory] [InlineData(AzureDevOpsGitUrl)] [InlineData("https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site/")] + [InlineData("git@ssh.dev.azure.com:v3/example/example%20web%20site/example%20web%20site")] public void GetProjectNameFromGitUrl_ShouldDecodePercentEncodedRepositoryNames(string gitUrl) { // Act diff --git a/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs b/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs index 8435a6c..482097e 100644 --- a/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs +++ b/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs @@ -6,9 +6,24 @@ internal static partial class GitUrlUtilities { internal static string GetProjectNameFromGitUrl(string gitUrl) { - string normalizedGitUrl = gitUrl.TrimEnd('/').RemovePostfix(".git"); - string encodedProjectName = normalizedGitUrl[(normalizedGitUrl.LastIndexOf('/') + 1)..]; - string projectName = Uri.UnescapeDataString(encodedProjectName); + ReadOnlySpan gitUrlSpan = gitUrl.AsSpan().TrimEnd('/'); + int projectNameStart = gitUrlSpan.LastIndexOf('/'); + + if (projectNameStart < 0 || projectNameStart == gitUrlSpan.Length - 1) + { + throw new ArgumentException("Git URL could not be parsed.", nameof(gitUrl)); + } + + ReadOnlySpan encodedProjectName = gitUrlSpan[(projectNameStart + 1)..]; + + if (encodedProjectName.EndsWith(".git", StringComparison.Ordinal)) + { + encodedProjectName = encodedProjectName[..^4]; + } + + string projectName = encodedProjectName.Contains('%') + ? Uri.UnescapeDataString(encodedProjectName.ToString()) + : encodedProjectName.ToString(); if (string.IsNullOrWhiteSpace(projectName) || projectName is "." or ".." ||