diff --git a/src/Core/RemoteRef.cs b/src/Core/RemoteRef.cs index ec8fdae..00473d7 100644 --- a/src/Core/RemoteRef.cs +++ b/src/Core/RemoteRef.cs @@ -15,7 +15,21 @@ public override string ToString() => public static bool TryParse(string value, [NotNullWhen(true)] out RemoteRef? remote) { - if (string.IsNullOrEmpty(value) || ParseExp().Match(value) is not { Success: true } match) + Match GetMatch(string input) + { + // Try Azure DevOps first since it is more specific + var match = ParseAzureDevOpsExp().Match(input); + + if (match.Success) + { + return match; + } + + return ParseExp().Match(input); + } + + if (string.IsNullOrEmpty(value) + || GetMatch(value) is not { Success: true } match) { remote = null; return false; @@ -28,10 +42,7 @@ public static bool TryParse(string value, [NotNullWhen(true)] out RemoteRef? rem var reference = match.Groups["ref"].Value; var filePath = match.Groups["path"].Value; - // If a third segment was provided, treat the originally parsed as the Azure DevOps project - if (!string.IsNullOrEmpty(project)) - (project, repo) = (repo, project); - else + if (string.IsNullOrEmpty(project)) project = null; // If project is provided, host is required, since GH does not support projects @@ -57,4 +68,7 @@ public static bool TryParse(string value, [NotNullWhen(true)] out RemoteRef? rem [GeneratedRegex(@"^(?:(?[A-Za-z0-9.-]+\.[A-Za-z]{2,})/)?(?[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38})/(?[A-Za-z0-9._-]{1,100})(?:/(?[A-Za-z0-9._-]{1,100}))?(?:@(?[^:\s]+))?(?::(?.+))?$")] private static partial Regex ParseExp(); + + [GeneratedRegex(@"^(?:(?dev.azure.com)/)(?[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38})/(?[%A-Za-z0-9._-]{1,100})(?:/(?[%A-Za-z0-9._-]{1,100}))?(?:@(?[^:\s]+))?(?::(?.+))?$")] + private static partial Regex ParseAzureDevOpsExp(); } diff --git a/src/Tests/Tests.cs b/src/Tests/Tests.cs index 9c3aa28..315ec60 100644 --- a/src/Tests/Tests.cs +++ b/src/Tests/Tests.cs @@ -251,6 +251,8 @@ public void TryParse_EdgeCaseMinimalPaths_WorksCorrectly(string input, string ex [InlineData("git.example.com/company/app", "git.example.com", "company", "app", null, null)] [InlineData("source.internal.org/internal/project", "source.internal.org", "internal", "project", null, null)] [InlineData("dev.azure.com/org/project/repo", "dev.azure.com", "org", "repo", null, null)] + [InlineData("dev.azure.com/org/project/repo%20space", "dev.azure.com", "org", "repo%20space", null, null)] + [InlineData("dev.azure.com/org/project%20space/repo%20space", "dev.azure.com", "org", "repo%20space", null, null)] [InlineData("git.sr.ht/user/repo", "git.sr.ht", "user", "repo", null, null)] public void TryParse_WithHost_SetsHostAndOwnerRepo(string input, string expectedHost, string expectedOwner, string expectedRepo, string? expectedBranch, string? expectedPath) { @@ -273,7 +275,7 @@ public void TryParse_WithHost_SetsHostAndOwnerRepo(string input, string expected [InlineData("codeberg.org/dev/tool@v1.0.0", "codeberg.org", "dev", "tool", "v1.0.0", null)] [InlineData("git.example.com/company/app@release/2024", "git.example.com", "company", "app", "release/2024", null)] [InlineData("source.internal.org/internal/project@hotfix/urgent", "source.internal.org", "internal", "project", "hotfix/urgent", null)] - [InlineData("dev.azure.com/org/project@refs/heads/main", "dev.azure.com", "org", "project", "refs/heads/main", null)] + [InlineData("dev.azure.com/org/project/repo@refs/heads/main", "dev.azure.com", "org", "repo", "refs/heads/main", null)] [InlineData("git.sr.ht/user/repo@master", "git.sr.ht", "user", "repo", "master", null)] public void TryParse_WithHostAndBranch_SetsHostOwnerRepoAndBranch(string input, string expectedHost, string expectedOwner, string expectedRepo, string expectedBranch, string? expectedPath) { @@ -296,7 +298,7 @@ public void TryParse_WithHostAndBranch_SetsHostOwnerRepoAndBranch(string input, [InlineData("codeberg.org/dev/tool:lib/utils.py", "codeberg.org", "dev", "tool", null, "lib/utils.py")] [InlineData("git.example.com/company/app:frontend/index.html", "git.example.com", "company", "app", null, "frontend/index.html")] [InlineData("source.internal.org/internal/project:scripts/deploy.sh", "source.internal.org", "internal", "project", null, "scripts/deploy.sh")] - [InlineData("dev.azure.com/org/project:azure-pipelines.yml", "dev.azure.com", "org", "project", null, "azure-pipelines.yml")] + [InlineData("dev.azure.com/org/project/repo:azure-pipelines.yml", "dev.azure.com", "org", "repo", null, "azure-pipelines.yml")] [InlineData("git.sr.ht/user/repo:Makefile", "git.sr.ht", "user", "repo", null, "Makefile")] public void TryParse_WithHostAndPath_SetsHostOwnerRepoAndPath(string input, string expectedHost, string expectedOwner, string expectedRepo, string? expectedBranch, string expectedPath) { @@ -319,7 +321,7 @@ public void TryParse_WithHostAndPath_SetsHostOwnerRepoAndPath(string input, stri [InlineData("codeberg.org/dev/tool@v2.1.0:lib/utils.py", "codeberg.org", "dev", "tool", "v2.1.0", "lib/utils.py")] [InlineData("git.example.com/company/app@release/2024:frontend/index.html", "git.example.com", "company", "app", "release/2024", "frontend/index.html")] [InlineData("source.internal.org/internal/project@hotfix/critical:scripts/deploy.sh", "source.internal.org", "internal", "project", "hotfix/critical", "scripts/deploy.sh")] - [InlineData("dev.azure.com/org/project@refs/heads/feature:azure-pipelines.yml", "dev.azure.com", "org", "project", "refs/heads/feature", "azure-pipelines.yml")] + [InlineData("dev.azure.com/org/project/repo@refs/heads/feature:azure-pipelines.yml", "dev.azure.com", "org", "repo", "refs/heads/feature", "azure-pipelines.yml")] [InlineData("git.sr.ht/user/repo@experimental:Makefile", "git.sr.ht", "user", "repo", "experimental", "Makefile")] public void TryParse_WithHostBranchAndPath_SetsAllProperties(string input, string expectedHost, string expectedOwner, string expectedRepo, string expectedBranch, string expectedPath) {