From dc81098376228f3358b398430d2517a263de0d49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 18:41:59 +0000 Subject: [PATCH 1/3] Match branch patterns on UTF-8 bytes, as git branch --list does [patch] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BranchPattern compared UTF-16 chars while git's wildmatch compares bytes, so outside ASCII the two picked out different branches: origin/? matched origin/é here but not in git, and origin/caf?? matched origin/café in git but not here. The pattern and the name are now spelled as their UTF-8 bytes, one character per byte, before matching. Every character the matcher treats as syntax is ASCII and every byte of a multi-byte character is above U+007F, so ASCII behaviour, bracket expressions and POSIX classes are unchanged. A new parity test runs 41 patterns through both BranchPattern and git branch -r --list over the same 25 refs and requires them to agree. Fixes #115 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW24ManL6mVAsf34qvT --- .../Git/BranchPatternGitParityTests.cs | 125 ++++++++++++++++++ .../Refs/BranchPatternTests.cs | 17 +++ GitBranchStateCache/Refs/BranchPattern.cs | 35 ++++- README.md | 2 +- 4 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs diff --git a/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs b/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs new file mode 100644 index 0000000..dfc8db6 --- /dev/null +++ b/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs @@ -0,0 +1,125 @@ +// 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(); + + foreach (string name in BranchNames) + { + Git("update-ref", $"refs/remotes/origin/{name}", head); + } + } + + [ClassCleanup] + public static void RemoveFixture() + { + try + { + 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 From 6ffe4b50c36b2b04e24963605016cddb56424c66 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 18:44:33 +0000 Subject: [PATCH 2/3] Write the parity fixture's refs into packed-refs Loose ref files would let a case-insensitive file system on the macOS and Windows CI legs fold origin/main and origin/Main into one ref. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW24ManL6mVAsf34qvT --- .../Git/BranchPatternGitParityTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs b/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs index dfc8db6..0547725 100644 --- a/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs +++ b/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs @@ -45,10 +45,16 @@ public static void BuildFixture(TestContext context) 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) { - Git("update-ref", $"refs/remotes/origin/{name}", head); + 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] From cd3087693629c80db320a523437f996522e2f6e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 18:49:35 +0000 Subject: [PATCH 3/3] Clear read-only attributes before removing the parity fixture On Windows git's object files are read-only, so Directory.Delete threw UnauthorizedAccessException from ClassCleanup. Same handling as RealRepositoryTests.RemoveFixture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW24ManL6mVAsf34qvT --- .../Git/BranchPatternGitParityTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs b/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs index 0547725..b353300 100644 --- a/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs +++ b/GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs @@ -62,6 +62,12 @@ 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)