Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions GitBranchStateCache.Tests/Git/BranchPatternGitParityTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Runs patterns through both <see cref="BranchPattern"/> and <c>git branch -r --list</c> over the
/// same refs, because the README promises they pick out the same branches.
/// </summary>
[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<string> disagreements = [];

foreach (string text in Patterns)
{
Assert.IsTrue(BranchPattern.TryParse(text, out BranchPattern? pattern, out string? failure), failure);

SortedSet<string> ours = new(refs.Where(pattern!.Matches), StringComparer.Ordinal);
SortedSet<string> 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}");
}
}
17 changes: 17 additions & 0 deletions GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
35 changes: 33 additions & 2 deletions GitBranchStateCache/Refs/BranchPattern.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace ktsu.GitBranchStateCache.Refs;

using System.Text;

/// <summary>
/// A branch name pattern, matched the way git matches one.
/// </summary>
Expand Down Expand Up @@ -35,6 +37,14 @@
/// <para>
/// Matching is case sensitive, because git ref names are.
/// </para>
/// <para>
/// 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: <c>?</c> and a bracket expression each match one byte, so
/// <c>origin/?</c> does not match <c>origin/é</c> (two bytes) but <c>origin/caf??</c> matches
/// <c>origin/café</c>, and <c>[é]</c> matches neither byte of an <c>é</c> on its own. That is
/// surprising, but a pattern a client tried with <c>git branch --list</c> has to pick out the same
/// branches here (ktsu-dev/GitBranchStateCache#115).
/// </para>
/// </remarks>
public sealed class BranchPattern
{
Expand All @@ -47,7 +57,14 @@
/// </remarks>
private const int MaximumLength = 512;

private BranchPattern(string text) => Text = text;
/// <summary>The pattern's UTF-8 bytes, one character per byte.</summary>
private readonly string _bytes;

private BranchPattern(string text)
{
Text = text;
_bytes = ToByteString(text);
}

/// <summary>Gets the pattern as the client sent it.</summary>
public string Text { get; }
Expand Down Expand Up @@ -126,9 +143,23 @@
public bool Matches(string branchName)
{
Ensure.NotNull(branchName);
return Matches(Text.AsSpan(), branchName.AsSpan());
return Matches(_bytes.AsSpan(), ToByteString(branchName).AsSpan());
}

/// <summary>
/// Spells a string as its UTF-8 bytes, one character per byte.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="text">The pattern or name.</param>
/// <returns>The bytes as characters U+0000 to U+00FF.</returns>
private static string ToByteString(string text) =>
Encoding.Latin1.GetString(Encoding.UTF8.GetBytes(text));

/// <summary>
/// Matches a pattern against a name.
/// </summary>
Expand Down Expand Up @@ -241,7 +272,7 @@
/// <see langword="false"/> when the bracket is never closed, in which case the <c>[</c> is an
/// ordinary character.
/// </returns>
private static bool TryWalkBracket(ReadOnlySpan<char> pattern, int start, char character, out int length, out bool matched, out string? unknownClass)

Check warning on line 275 in GitBranchStateCache/Refs/BranchPattern.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 275 in GitBranchStateCache/Refs/BranchPattern.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.
{
unknownClass = null;
int index = start + 1;
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading