diff --git a/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs b/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
index d29108f..ed47fcd 100644
--- a/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
+++ b/GitBranchStateCache.Tests/Refs/BranchPatternTests.cs
@@ -78,6 +78,87 @@ public void Matches_RegularExpressionMetacharacters_AreLiteral()
Assert.IsFalse(Parse("origin/v1.0").Matches("origin/v1x0"));
}
+ [TestMethod]
+ public void Matches_BracketRange_MatchesTheWayGitBranchListDoes()
+ {
+ // The shape a StatusBranchNamePatterns entry takes in the field. git branch --list honours the
+ // bracket expression, so the service has to, or those branches silently drop out of /state.
+ BranchPattern pattern = Parse("origin/release-[0-9]*");
+
+ Assert.IsTrue(pattern.Matches("origin/release-1.0"));
+ Assert.IsTrue(pattern.Matches("origin/release-2026"));
+ Assert.IsFalse(pattern.Matches("origin/release-x"));
+ }
+
+ [TestMethod]
+ public void Matches_BracketSet_MatchesAnyMember()
+ {
+ BranchPattern pattern = Parse("origin/[abc]");
+
+ Assert.IsTrue(pattern.Matches("origin/a"));
+ Assert.IsTrue(pattern.Matches("origin/c"));
+ Assert.IsFalse(pattern.Matches("origin/d"));
+ Assert.IsFalse(pattern.Matches("origin/ab"));
+ }
+
+ [TestMethod]
+ public void Matches_NegatedBracket_AcceptsBangAndCaret()
+ {
+ foreach (string text in new[] { "origin/[!x]y", "origin/[^x]y" })
+ {
+ BranchPattern pattern = Parse(text);
+
+ Assert.IsTrue(pattern.Matches("origin/ay"), text);
+ Assert.IsFalse(pattern.Matches("origin/xy"), text);
+ }
+ }
+
+ [TestMethod]
+ public void Matches_CloseBracketFirst_IsAMember()
+ {
+ Assert.IsTrue(Parse("a[]b]c").Matches("a]c"));
+ Assert.IsTrue(Parse("a[]b]c").Matches("abc"));
+ Assert.IsFalse(Parse("a[!]]c").Matches("a]c"));
+ Assert.IsTrue(Parse("a[!]]c").Matches("abc"));
+ }
+
+ [TestMethod]
+ public void Matches_DashAtEitherEnd_IsAMember()
+ {
+ Assert.IsTrue(Parse("v[-.]1").Matches("v-1"));
+ Assert.IsTrue(Parse("v[.-]1").Matches("v-1"));
+ Assert.IsFalse(Parse("v[.-]1").Matches("v/1"));
+ }
+
+ [TestMethod]
+ public void Matches_Backslash_EscapesTheNextCharacter()
+ {
+ Assert.IsTrue(Parse(@"origin/a\*b").Matches("origin/a*b"));
+ Assert.IsFalse(Parse(@"origin/a\*b").Matches("origin/axyb"));
+ Assert.IsTrue(Parse(@"origin/\[x]").Matches("origin/[x]"));
+ Assert.IsFalse(Parse(@"origin/\[x]").Matches("origin/x"));
+ Assert.IsTrue(Parse(@"origin/[\]]").Matches("origin/]"));
+ }
+
+ [TestMethod]
+ public void Matches_UnclosedBracket_IsLiteral()
+ {
+ Assert.IsTrue(Parse("origin/[main").Matches("origin/[main"));
+ Assert.IsFalse(Parse("origin/[main").Matches("origin/main"));
+ }
+
+ [TestMethod]
+ public void Matches_BracketAfterWildcard_BacktracksCorrectly() =>
+ Assert.IsTrue(Parse("*/v[0-9].[0-9]").Matches("origin/feature/v1.2"));
+
+ [TestMethod]
+ public void Matches_PathologicalBracketPattern_StillReturnsPromptly()
+ {
+ BranchPattern pattern = Parse(string.Concat(Enumerable.Repeat("*[a-y]", 40)) + "z");
+
+ Assert.IsFalse(pattern.Matches(new string('a', 4000)));
+ }
+
[TestMethod]
public void Matches_PathologicalPattern_StillReturnsPromptly()
{
diff --git a/GitBranchStateCache/Refs/BranchPattern.cs b/GitBranchStateCache/Refs/BranchPattern.cs
index 4584985..ceda248 100644
--- a/GitBranchStateCache/Refs/BranchPattern.cs
+++ b/GitBranchStateCache/Refs/BranchPattern.cs
@@ -19,6 +19,12 @@ namespace ktsu.GitBranchStateCache.Refs;
/// matcher backtracks at most once per wildcard and cannot be made to run long.
///
///
+/// The same constructs git's wildmatch honours for git branch --list are honoured here:
+/// *, ?, bracket expressions such as [0-9], [abc], [!x] and
+/// [^x] (a ] placed first is literal), and \ to escape the next character. A
+/// [ with no closing ] is an ordinary character.
+///
+///
/// Matching is case sensitive, because git ref names are.
///
///
@@ -81,8 +87,9 @@ public bool Matches(string branchName)
///
/// The classic linear wildcard match: walk both sides together, and on a mismatch fall back to the
/// most recent star and let it consume one more character. Because only the most recent star is
- /// ever revisited, the work is bounded by the product of the two lengths in the worst case and is
- /// linear in practice, with no recursion and nothing to backtrack exponentially.
+ /// ever revisited, the work is bounded by the product of the two lengths (times the length of a
+ /// bracket expression, which is itself bounded by the pattern) and is linear in practice, with no
+ /// recursion and nothing to backtrack exponentially.
///
private static bool Matches(ReadOnlySpan pattern, ReadOnlySpan name)
{
@@ -93,17 +100,17 @@ private static bool Matches(ReadOnlySpan pattern, ReadOnlySpan name)
while (nameIndex < name.Length)
{
- if (patternIndex < pattern.Length && (pattern[patternIndex] == '?' || pattern[patternIndex] == name[nameIndex]))
- {
- patternIndex++;
- nameIndex++;
- }
- else if (patternIndex < pattern.Length && pattern[patternIndex] == '*')
+ if (patternIndex < pattern.Length && pattern[patternIndex] == '*')
{
starIndex = patternIndex;
resumeIndex = nameIndex;
patternIndex++;
}
+ else if (patternIndex < pattern.Length && MatchesOne(pattern, patternIndex, name[nameIndex], out int length))
+ {
+ patternIndex += length;
+ nameIndex++;
+ }
else if (starIndex >= 0)
{
patternIndex = starIndex + 1;
@@ -123,4 +130,149 @@ private static bool Matches(ReadOnlySpan pattern, ReadOnlySpan name)
return patternIndex == pattern.Length;
}
+
+ ///
+ /// Matches the one pattern element starting at against one character.
+ ///
+ /// The whole pattern.
+ /// Where the element starts. It is not a star.
+ /// The character from the name.
+ /// How many pattern characters the element spans.
+ /// when the element matches the character.
+ private static bool MatchesOne(ReadOnlySpan pattern, int index, char character, out int length)
+ {
+ char element = pattern[index];
+
+ if (element == '?')
+ {
+ length = 1;
+ return true;
+ }
+
+ if (element == '\\' && index + 1 < pattern.Length)
+ {
+ length = 2;
+ return pattern[index + 1] == character;
+ }
+
+ if (element == '[' && TryMatchBracket(pattern, index, character, out length, out bool matched))
+ {
+ return matched;
+ }
+
+ length = 1;
+ return element == character;
+ }
+
+ ///
+ /// Matches a bracket expression such as [0-9] or [!abc] against one character.
+ ///
+ /// The whole pattern.
+ /// Where the opening [ is.
+ /// The character from the name.
+ /// How many pattern characters the expression spans, including both brackets.
+ /// Whether the expression matches the character.
+ ///
+ /// when the bracket is never closed, in which case the [ is an
+ /// ordinary character.
+ ///
+ private static bool TryMatchBracket(ReadOnlySpan pattern, int start, char character, out int length, out bool matched)
+ {
+ int index = start + 1;
+ bool negated = index < pattern.Length && (pattern[index] == '!' || pattern[index] == '^');
+ if (negated)
+ {
+ index++;
+ }
+
+ bool found = false;
+ bool first = true;
+
+ // A ] straight after the opening bracket (or its negation) is a member, not the end.
+ while (index < pattern.Length && (first || pattern[index] != ']'))
+ {
+ first = false;
+
+ if (!TryReadRange(pattern, ref index, out char low, out char high))
+ {
+ break;
+ }
+
+ found |= low <= character && character <= high;
+ }
+
+ if (index >= pattern.Length)
+ {
+ length = 1;
+ matched = false;
+ return false;
+ }
+
+ length = index + 1 - start;
+ matched = found != negated;
+ return true;
+ }
+
+ ///
+ /// Reads one member of a bracket expression: a single character, or a range such as a-z.
+ ///
+ /// The whole pattern.
+ /// Where the member starts; moved past it.
+ /// The first character the member covers.
+ /// The last character the member covers; equal to for a single character.
+ ///
+ /// when the pattern ends in a lone backslash, which leaves the bracket
+ /// unclosed.
+ ///
+ ///
+ /// A - directly before the closing ] is an ordinary member, not the start of a range.
+ ///
+ private static bool TryReadRange(ReadOnlySpan pattern, ref int index, out char low, out char high)
+ {
+ if (!TryReadMember(pattern, ref index, out low))
+ {
+ high = default;
+ return false;
+ }
+
+ high = low;
+ if (index + 1 < pattern.Length && pattern[index] == '-' && pattern[index + 1] != ']')
+ {
+ index++;
+ return TryReadMember(pattern, ref index, out high);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Reads one member character of a bracket expression, honouring a backslash escape.
+ ///
+ /// The whole pattern.
+ /// Where the member starts; moved past it.
+ /// The member character.
+ ///
+ /// when the pattern ends in a lone backslash, which leaves the bracket
+ /// unclosed.
+ ///
+ private static bool TryReadMember(ReadOnlySpan pattern, ref int index, out char value)
+ {
+ if (pattern[index] == '\\')
+ {
+ if (index + 1 >= pattern.Length)
+ {
+ index = pattern.Length;
+ value = default;
+ return false;
+ }
+
+ value = pattern[index + 1];
+ index += 2;
+ return true;
+ }
+
+ value = pattern[index];
+ index++;
+ return true;
+ }
}