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