From fb40bdc0ac152dedbfec27f33d6e417462be7e75 Mon Sep 17 00:00:00 2001 From: Adrian Duke Date: Thu, 30 Apr 2026 17:31:11 +0100 Subject: [PATCH] radicle/git/canonical: Fix glob rewrite rule for trailing asterisk Previously a rule like `refs/heads/releases-*` would be rewritten incorrectly to `refs/heads/releases-**/*` matching the 'trailing asterisk' rule. Simplify the rewrite rule so that a trailing `*` becomes a prefix matching rule. Add tests. --- CHANGELOG.md | 7 ++ crates/radicle/src/git/canonical/rules.rs | 19 ++--- .../radicle/src/git/canonical/rules/test.rs | 73 +++++++++++++++++++ 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9facde4c..fb9920d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,13 @@ COB type names and payload IDs remain unchanged for backwards compatibility. - Delete invalid node addresses, of the IPv6 type, from the SQLite address book. Entries deleted include ones that have an address of the form `[]:port`, or that have an invalid address within the `[]`. +- Correctly handle the canonical reference rules that have a glob star suffix, + e.g., `refs/heads/main*`, `refs/heads/main-*`, etc. + Previously, these would be expanded into sub-component searches. For example, + in the previous examples, the search would be expanded to + `refs/heads/main**/*` and `refs/heads/main-**/*`. + Now, the rule will match by prefix, e.g. `refs/heads/main-*` will match + `refs/heads/main-a`, `refs/heads/main-b`, `refs/heads/main-deadbeef`, etc. ## 1.8.0 diff --git a/crates/radicle/src/git/canonical/rules.rs b/crates/radicle/src/git/canonical/rules.rs index c3fbb117..a6a99966 100644 --- a/crates/radicle/src/git/canonical/rules.rs +++ b/crates/radicle/src/git/canonical/rules.rs @@ -54,25 +54,18 @@ fn matches(pattern: &RawPattern, refname: &Qualified) -> bool { // that namespace, even if they are further down the hierarchy. // Thus, the following rules are applied: // - // - a trailing `*` changes to `**/*` + // - a trailing `*` matches anything that starts with the prefix // - a `*` in between path components changes to `**` - let spec = match pattern.as_str().split_once(ASTERISK) { - None => pattern.to_string(), - // Expand `refs/tags/*` to `refs/tags/**/*` - Some((prefix, "")) => { - let mut spec = prefix.to_string(); - spec.push_str("**/*"); - spec - } - // Expand `refs/tags/*/v1.0` to `refs/tags/**/v1.0` + match pattern.as_str().split_once(ASTERISK) { + None => pattern.as_str() == refname.as_str(), + Some((prefix, "")) => refname.as_str().starts_with(prefix), Some((prefix, suffix)) => { let mut spec = prefix.to_string(); spec.push_str("**"); spec.push_str(suffix); - spec + fast_glob::glob_match(&spec, refname.as_str()) } - }; - fast_glob::glob_match(&spec, refname.as_str()) + } } /// Patterns are ordered by their specificity. diff --git a/crates/radicle/src/git/canonical/rules/test.rs b/crates/radicle/src/git/canonical/rules/test.rs index dd9532d8..089f647a 100644 --- a/crates/radicle/src/git/canonical/rules/test.rs +++ b/crates/radicle/src/git/canonical/rules/test.rs @@ -477,3 +477,76 @@ fn special_branches() { assert!(Pattern::new((*SIGREFS_PARENT).clone().into()).is_err()); assert!(Pattern::new((*IDENTITY_ROOT).clone().into()).is_err()); } + +#[test] +fn matches_expands_globs_appropriately() { + let exact = qualified_pattern!("refs/heads/main"); + assert!(matches(&exact, &git::fmt::qualified!("refs/heads/main"))); + assert!(!matches(&exact, &git::fmt::qualified!("refs/heads/main-2"))); + assert!(!matches(&exact, &git::fmt::qualified!("refs/heads/other"))); + + let trailing_slash = qualified_pattern!("refs/heads/*"); + assert!(matches( + &trailing_slash, + &git::fmt::qualified!("refs/heads/main") + )); + assert!(matches( + &trailing_slash, + &git::fmt::qualified!("refs/heads/feature/1") + )); + assert!(!matches( + &trailing_slash, + &git::fmt::qualified!("refs/tags/main") + )); + + let trailing_text = qualified_pattern!("refs/heads/feature-*"); + assert!(matches( + &trailing_text, + &git::fmt::qualified!("refs/heads/feature-") + )); + assert!(matches( + &trailing_text, + &git::fmt::qualified!("refs/heads/feature-1") + )); + assert!(matches( + &trailing_text, + &git::fmt::qualified!("refs/heads/feature-1/sub") + )); + assert!(!matches( + &trailing_text, + &git::fmt::qualified!("refs/heads/feature") + )); + + // Because `*` expands to `**`, it matches zero or more path components. + let middle = qualified_pattern!("refs/heads/*/main"); + assert!(matches( + &middle, + &git::fmt::qualified!("refs/heads/alice/main") + )); + assert!(matches( + &middle, + &git::fmt::qualified!("refs/heads/alice/bob/main") + )); + + // + // NOTE(Ade): In git this won't match as glob '*' isn't inclusive of zero matches like `**` + // See: https://git.kernel.org/pub/scm/git/git.git/tree/refspec.c?h=v2.54.0&id=94f057755b7941b321fd11fec1b2e3ca5313a4e0#n298 + // + assert!(matches(&middle, &git::fmt::qualified!("refs/heads/main"))); + + assert!(!matches( + &middle, + &git::fmt::qualified!("refs/heads/alice/dev") + )); + + // HardenedBSD glob expansion issue + let hbsd = qualified_pattern!("refs/heads/quarterly/hardened/15-stable/main*"); + assert!(matches( + &hbsd, + &git::fmt::qualified!("refs/heads/quarterly/hardened/15-stable/main-2026q2") + )); + assert!(matches( + &hbsd, + &git::fmt::qualified!("refs/heads/quarterly/hardened/15-stable/main/2026q2") + )); +}