From 07dedac8d78386deec45a332dd5e032c34a96adb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:25:23 +0000 Subject: [PATCH 1/2] fix: honour [...] bracket expressions and backslash escapes in branch patterns [patch] BranchPattern promised to match the way git branch --list does, but only understood * and ?. A pattern such as origin/release-[0-9]* was accepted and then matched nothing, so those branches silently dropped out of /state and /branches. The matcher now handles ranges, sets, negation with ! or ^, a literal ] placed first, and \ escapes, keeping the linear no-regex walk. An unclosed [ stays an ordinary character. A * is now checked before a literal match, so a star always acts as a wildcard. Fixes ktsu-dev/GitBranchStateCache#43 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PtWVWaDh3nqJL1eKD7vFxU --- .../Refs/BranchPatternTests.cs | 81 ++++++++++ GitBranchStateCache/Refs/BranchPattern.cs | 149 +++++++++++++++++- 2 files changed, 222 insertions(+), 8 deletions(-) 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..2d36303 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,130 @@ 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 (!TryReadMember(pattern, ref index, out char low)) + { + break; + } + + char high = low; + if (index + 1 < pattern.Length && pattern[index] == '-' && pattern[index + 1] != ']') + { + index++; + if (!TryReadMember(pattern, ref index, out high)) + { + break; + } + } + + if (low <= character && character <= high) + { + found = true; + } + } + + if (index >= pattern.Length) + { + length = 1; + matched = false; + return false; + } + + length = index + 1 - start; + matched = found != negated; + 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; + } } From cd84d5929bd2ac7042c6d1587efaf371022f9338 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:38:27 +0000 Subject: [PATCH 2/2] Read a bracket member's range in its own helper Splits the range parsing out of TryMatchBracket so the method stays under the cognitive-complexity limit. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PtWVWaDh3nqJL1eKD7vFxU --- GitBranchStateCache/Refs/BranchPattern.cs | 49 ++++++++++++++++------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/GitBranchStateCache/Refs/BranchPattern.cs b/GitBranchStateCache/Refs/BranchPattern.cs index 2d36303..ceda248 100644 --- a/GitBranchStateCache/Refs/BranchPattern.cs +++ b/GitBranchStateCache/Refs/BranchPattern.cs @@ -193,25 +193,12 @@ private static bool TryMatchBracket(ReadOnlySpan pattern, int start, char { first = false; - if (!TryReadMember(pattern, ref index, out char low)) + if (!TryReadRange(pattern, ref index, out char low, out char high)) { break; } - char high = low; - if (index + 1 < pattern.Length && pattern[index] == '-' && pattern[index + 1] != ']') - { - index++; - if (!TryReadMember(pattern, ref index, out high)) - { - break; - } - } - - if (low <= character && character <= high) - { - found = true; - } + found |= low <= character && character <= high; } if (index >= pattern.Length) @@ -226,6 +213,38 @@ private static bool TryMatchBracket(ReadOnlySpan pattern, int start, char 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. ///