diff --git a/BuildMonitor.Test/BranchUrlTests.cs b/BuildMonitor.Test/BranchUrlTests.cs
new file mode 100644
index 0000000..c7a0918
--- /dev/null
+++ b/BuildMonitor.Test/BranchUrlTests.cs
@@ -0,0 +1,47 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.BuildMonitor.Test;
+
+using ktsu.Semantics.Strings;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Covers "Open Branch URL" and "Copy Branch URL". The branch name went into the URL unescaped, so a
+/// # started a fragment and a % read as a percent-escape, and the link pointed at the
+/// wrong branch or a 404 (ktsu-dev/BuildMonitor#386).
+///
+[TestClass]
+public sealed class BranchUrlTests
+{
+ private sealed class StubProvider : BuildProvider
+ {
+ internal override BuildProviderName Name { get; } = "Stub".As();
+
+ internal override Task UpdateRepositoriesAsync(Owner owner) => Task.CompletedTask;
+ internal override Task UpdateBuildsAsync(Repository repository) => Task.CompletedTask;
+ internal override Task UpdateBuildAsync(Build build) => Task.CompletedTask;
+ internal override Task UpdateRunAsync(Run run) => Task.CompletedTask;
+ }
+
+ private static Build CreateBuild()
+ {
+ Owner owner = new StubProvider().CreateOwner("o".As());
+ Repository repository = owner.CreateRepository("r".As());
+ return repository.CreateBuild("build".As());
+ }
+
+ [TestMethod]
+ [DataRow("feature/foo", "https://github.com/o/r/tree/feature/foo")]
+ [DataRow("main", "https://github.com/o/r/tree/main")]
+ [DataRow("fix/#123-crash", "https://github.com/o/r/tree/fix/%23123-crash")]
+ [DataRow("feature/50%", "https://github.com/o/r/tree/feature/50%25")]
+ public void BranchUrlEscapesEachSegmentAndKeepsSeparators(string branch, string expected)
+ {
+ // Act
+ string url = BuildMonitor.GetBranchUrl(CreateBuild(), branch.As());
+
+ // Assert
+ Assert.AreEqual(expected, url);
+ }
+}
diff --git a/BuildMonitor/BuildMonitor.cs b/BuildMonitor/BuildMonitor.cs
index 1f2e783..8ce14bc 100644
--- a/BuildMonitor/BuildMonitor.cs
+++ b/BuildMonitor/BuildMonitor.cs
@@ -1338,7 +1338,12 @@ private static void CopyRunUrl(Run run)
private static string GetWorkflowUrl(Build build) => $"https://github.com/{build.Owner.Name}/{build.Repository.Name}/actions/workflows/{build.Id}";
- private static string GetBranchUrl(Build build, BranchName branch) => $"https://github.com/{build.Owner.Name}/{build.Repository.Name}/tree/{branch}";
+ ///
+ /// Escapes each segment of the branch name and keeps its / separators, so a # or
+ /// % in the name stays part of the path rather than starting a fragment or an escape.
+ ///
+ internal static string GetBranchUrl(Build build, BranchName branch) =>
+ $"https://github.com/{build.Owner.Name}/{build.Repository.Name}/tree/{string.Join('/', branch.ToString().Split('/').Select(Uri.EscapeDataString))}";
private static string GetRunUrl(Run run) => $"https://github.com/{run.Owner.Name}/{run.Repository.Name}/actions/runs/{run.Id}";