diff --git a/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs b/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs
new file mode 100644
index 0000000..b353300
--- /dev/null
+++ b/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs
@@ -0,0 +1,137 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.GitBranchStateCache.Tests.Git;
+
+using System.Diagnostics;
+using System.Text;
+using ktsu.GitBranchStateCache.Refs;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Runs patterns through both and git branch -r --list over the
+/// same refs, because the README promises they pick out the same branches.
+///
+[TestClass]
+public class BranchPatternGitParityTests
+{
+ private static readonly string[] BranchNames =
+ [
+ "main", "Main", "develop", "release/1.0", "release/2.0", "release-1.0", "release-x", "v1.0", "v1x0",
+ "v-1", "feature/v1.2", "a]c", "abc", "b", "Z", "7", "_",
+ "é", "café", "cafe", "cafés", "naïve", "😀", "x😀", "日本",
+ ];
+
+ private static readonly string[] Patterns =
+ [
+ "origin/main", "origin/*", "*/main", "origin/release/*", "origin/release/?.0", "origin/mai.",
+ "origin/v1.0", "origin/release-[0-9]*", "origin/[abc]", "origin/a[]b]c", "origin/a[!]]c",
+ "origin/v[-.]1", "origin/v[.-]1", "origin/release-[[:digit:]]*", "origin/[[:alnum:]]",
+ "origin/[[:alpha:]_-]", "origin/[![:upper:]]", "origin/?", "origin/??", "origin/???",
+ "origin/????", "origin/[é]", "origin/[!a]", "origin/caf?", "origin/caf??", "origin/caf???",
+ "origin/café", "origin/caf\\é", "origin/caf*", "origin/*é", "origin/caf[!e]*", "origin/na?ve",
+ "origin/na??ve", "origin/x?", "origin/x????", "origin/[😀]", "origin/日?", "origin/日???",
+ "origin/[[:print:]]", "origin/[[:punct:]]", "origin/[!x]*",
+ ];
+
+ private static string _repository = string.Empty;
+
+ [ClassInitialize]
+ public static void BuildFixture(TestContext context)
+ {
+ _repository = Path.Combine(Path.GetTempPath(), $"gbsc-parity-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_repository);
+
+ Git("init", "--quiet", "--initial-branch=main");
+ Git("-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "--quiet", "--allow-empty", "-m", "initial");
+ string head = Git("rev-parse", "HEAD").Trim();
+
+ // Written straight into packed-refs rather than as loose ref files, so a case-insensitive file
+ // system (macOS, Windows) cannot fold "main" and "Main" into one ref. Unsorted, so the header
+ // does not claim otherwise.
+ StringBuilder packed = new("# pack-refs with: peeled fully-peeled \n");
+ foreach (string name in BranchNames)
+ {
+ packed.Append(head).Append(" refs/remotes/origin/").Append(name).Append('\n');
+ }
+
+ File.WriteAllText(Path.Combine(_repository, ".git", "packed-refs"), packed.ToString(), new UTF8Encoding(false));
+ }
+
+ [ClassCleanup]
+ public static void RemoveFixture()
+ {
+ try
+ {
+ // Git marks objects read-only, which Directory.Delete refuses on Windows.
+ foreach (string file in Directory.GetFiles(_repository, "*", SearchOption.AllDirectories))
+ {
+ File.SetAttributes(file, FileAttributes.Normal);
+ }
+
+ Directory.Delete(_repository, recursive: true);
+ }
+ catch (IOException)
+ {
+ // A leftover fixture in the temp directory is not worth failing a run over.
+ }
+ }
+
+ [TestMethod]
+ public void EveryPattern_PicksOutTheSameBranchesAsGitBranchList()
+ {
+ string[] refs = [.. BranchNames.Select(name => $"origin/{name}")];
+ List disagreements = [];
+
+ foreach (string text in Patterns)
+ {
+ Assert.IsTrue(BranchPattern.TryParse(text, out BranchPattern? pattern, out string? failure), failure);
+
+ SortedSet ours = new(refs.Where(pattern!.Matches), StringComparer.Ordinal);
+ SortedSet gits = new(
+ Git("branch", "-r", "--list", "--format=%(refname:lstrip=2)", text)
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries),
+ StringComparer.Ordinal);
+
+ if (!ours.SetEquals(gits))
+ {
+ disagreements.Add($"{text}: git [{string.Join(", ", gits)}], BranchPattern [{string.Join(", ", ours)}]");
+ }
+ }
+
+ Assert.IsEmpty(disagreements, string.Join(Environment.NewLine, disagreements));
+ }
+
+ private static string Git(params string[] arguments)
+ {
+ ProcessStartInfo startInfo = new()
+ {
+ FileName = "git",
+ WorkingDirectory = _repository,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ StandardOutputEncoding = new UTF8Encoding(false),
+ StandardErrorEncoding = new UTF8Encoding(false),
+ };
+
+ // Nothing configured for whoever runs the tests may change what git lists.
+ startInfo.Environment["GIT_CONFIG_NOSYSTEM"] = "1";
+ startInfo.Environment["GIT_CONFIG_GLOBAL"] = "/dev/null";
+
+ foreach (string argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ using Process process = Process.Start(startInfo)
+ ?? throw new InvalidOperationException("git could not be started.");
+
+ string output = process.StandardOutput.ReadToEnd();
+ string error = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+
+ return process.ExitCode == 0
+ ? output
+ : throw new InvalidOperationException($"git {string.Join(' ', arguments)} failed: {error}");
+ }
+}
diff --git a/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs b/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
index 0307aa7..f858039 100644
--- a/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
+++ b/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
@@ -248,4 +248,21 @@ public void TryParse_Empty_IsRefused() =>
[TestMethod]
public void TryParse_ExcessivelyLong_IsRefused() =>
Assert.IsFalse(BranchPattern.TryParse(new string('a', 513), out _, out _));
+
+ [TestMethod]
+ [DataRow("origin/?", "origin/é", false)]
+ [DataRow("origin/??", "origin/é", true)]
+ [DataRow("origin/[é]", "origin/é", false)]
+ [DataRow("origin/caf??", "origin/café", true)]
+ [DataRow("origin/caf?", "origin/café", false)]
+ [DataRow("origin/café", "origin/café", true)]
+ [DataRow("origin/caf*", "origin/café", true)]
+ [DataRow("origin/x????", "origin/x😀", true)]
+ [DataRow("origin/x??", "origin/x😀", false)]
+ public void Matches_OutsideAscii_CountsUtf8BytesLikeGit(string pattern, string name, bool expected)
+ {
+ // git's wildmatch compares bytes, so ? and a bracket expression each take one byte of a
+ // multi-byte character (#115). BranchPatternGitParityTests checks the same against git itself.
+ Assert.AreEqual(expected, Parse(pattern).Matches(name));
+ }
}
diff --git a/GitBranchStateCache/Refs/BranchPattern.cs b/GitBranchStateCache/Refs/BranchPattern.cs
index 6c72452..17fe058 100644
--- a/GitBranchStateCache/Refs/BranchPattern.cs
+++ b/GitBranchStateCache/Refs/BranchPattern.cs
@@ -2,6 +2,8 @@
namespace ktsu.GitBranchStateCache.Refs;
+using System.Text;
+
///
/// A branch name pattern, matched the way git matches one.
///
@@ -35,6 +37,14 @@ namespace ktsu.GitBranchStateCache.Refs;
///
/// Matching is case sensitive, because git ref names are.
///
+///
+/// Matching is on the UTF-8 bytes of the pattern and the name, because that is what git's wildmatch
+/// compares. Outside ASCII this is visible: ? and a bracket expression each match one byte, so
+/// origin/? does not match origin/é (two bytes) but origin/caf?? matches
+/// origin/café, and [é] matches neither byte of an é on its own. That is
+/// surprising, but a pattern a client tried with git branch --list has to pick out the same
+/// branches here (ktsu-dev/GitBranchStateCache#115).
+///
///
public sealed class BranchPattern
{
@@ -47,7 +57,14 @@ public sealed class BranchPattern
///
private const int MaximumLength = 512;
- private BranchPattern(string text) => Text = text;
+ /// The pattern's UTF-8 bytes, one character per byte.
+ private readonly string _bytes;
+
+ private BranchPattern(string text)
+ {
+ Text = text;
+ _bytes = ToByteString(text);
+ }
/// Gets the pattern as the client sent it.
public string Text { get; }
@@ -126,9 +143,23 @@ private static bool TryFindUnknownClass(ReadOnlySpan pattern, out string?
public bool Matches(string branchName)
{
Ensure.NotNull(branchName);
- return Matches(Text.AsSpan(), branchName.AsSpan());
+ return Matches(_bytes.AsSpan(), ToByteString(branchName).AsSpan());
}
+ ///
+ /// Spells a string as its UTF-8 bytes, one character per byte.
+ ///
+ ///
+ /// The matcher below then compares bytes, as git does, without being rewritten for them. Every
+ /// character it treats specially is ASCII, which UTF-8 leaves as one byte of the same value, and
+ /// every byte of a multi-byte character is above U+007F, so none of them is mistaken for syntax and
+ /// none falls in a POSIX class.
+ ///
+ /// The pattern or name.
+ /// The bytes as characters U+0000 to U+00FF.
+ private static string ToByteString(string text) =>
+ Encoding.Latin1.GetString(Encoding.UTF8.GetBytes(text));
+
///
/// Matches a pattern against a name.
///
diff --git a/README.md b/README.md
index b955ad3..f6bec77 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ A path absent from `paths` is unchanged on every queried branch relative to its
GET /v1/{upstream}/{repositoryPath}/branches?pattern=origin/release/*
```
-Resolves wildcard patterns against the mirror's refs and returns names with tip ids. This removes the other reason a client currently needs a network fetch on its heartbeat. Wildcards here cross path separators, matching `git branch --list`, so patterns a project already has keep meaning what they already mean.
+Resolves wildcard patterns against the mirror's refs and returns names with tip ids. This removes the other reason a client currently needs a network fetch on its heartbeat. Wildcards here cross path separators, matching `git branch --list`, so patterns a project already has keep meaning what they already mean. Like git, patterns are matched against the UTF-8 bytes of a name, so outside ASCII a `?` or a bracket expression matches one byte: `origin/caf??` matches `origin/café`, and `origin/caf?` does not.
### Health