Skip to content

Commit c2c1a61

Browse files
authored
Merge pull request #2238 from GitoxideLabs/copilot/update-refspec-parsing-logic
Allow complex glob patterns in one-sided refspecs with full wildmatch support
2 parents c400dd3 + ba2301f commit c2c1a61

File tree

9 files changed

+295
-19
lines changed

9 files changed

+295
-19
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gix-refspec/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ doctest = false
1818
gix-revision = { version = "^0.36.0", path = "../gix-revision", default-features = false }
1919
gix-validate = { version = "^0.10.1", path = "../gix-validate" }
2020
gix-hash = { version = "^0.20.0", path = "../gix-hash" }
21+
gix-glob = { version = "^0.22.1", path = "../gix-glob" }
2122

2223
bstr = { version = "1.12.0", default-features = false, features = ["std"] }
2324
thiserror = "2.0.17"
2425
smallvec = "1.15.1"
2526

2627
[dev-dependencies]
2728
gix-testtools = { path = "../tests/tools" }
29+
insta = "1.43.2"

gix-refspec/src/match_group/util.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use gix_hash::ObjectId;
66
use crate::{match_group::Item, RefSpecRef};
77

88
/// A type keeping enough information about a ref-spec to be able to efficiently match it against multiple matcher items.
9+
#[derive(Debug)]
910
pub struct Matcher<'a> {
1011
pub(crate) lhs: Option<Needle<'a>>,
1112
pub(crate) rhs: Option<Needle<'a>>,
@@ -46,6 +47,7 @@ pub(crate) enum Needle<'a> {
4647
FullName(&'a BStr),
4748
PartialName(&'a BStr),
4849
Glob { name: &'a BStr, asterisk_pos: usize },
50+
Pattern(&'a BStr),
4951
Object(ObjectId),
5052
}
5153

@@ -102,6 +104,17 @@ impl<'a> Needle<'a> {
102104
let end = item.full_ref_name.len() - tail.len();
103105
Match::GlobRange(*asterisk_pos..end)
104106
}
107+
Needle::Pattern(pattern) => {
108+
if gix_glob::wildmatch(
109+
pattern,
110+
item.full_ref_name,
111+
gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
112+
) {
113+
Match::Normal
114+
} else {
115+
Match::None
116+
}
117+
}
105118
Needle::Object(id) => {
106119
if *id == item.target {
107120
return Match::Normal;
@@ -137,7 +150,11 @@ impl<'a> Needle<'a> {
137150
name.insert_str(0, "refs/heads/");
138151
Cow::Owned(name.into())
139152
}
153+
(Needle::Pattern(name), None) => Cow::Borrowed(name),
140154
(Needle::Glob { .. }, None) => unreachable!("BUG: no range provided for glob pattern"),
155+
(Needle::Pattern(_), Some(_)) => {
156+
unreachable!("BUG: range provided for pattern, but patterns don't use ranges")
157+
}
141158
(_, Some(_)) => {
142159
unreachable!("BUG: range provided even though needle wasn't a glob. Globs are symmetric.")
143160
}
@@ -168,9 +185,28 @@ impl<'a> From<&'a BStr> for Needle<'a> {
168185

169186
impl<'a> From<RefSpecRef<'a>> for Matcher<'a> {
170187
fn from(v: RefSpecRef<'a>) -> Self {
171-
Matcher {
188+
let mut m = Matcher {
172189
lhs: v.src.map(Into::into),
173190
rhs: v.dst.map(Into::into),
191+
};
192+
if m.rhs.is_none() {
193+
if let Some(src) = v.src {
194+
if must_use_pattern_matching(src) {
195+
m.lhs = Some(Needle::Pattern(src));
196+
}
197+
}
174198
}
199+
m
200+
}
201+
}
202+
203+
/// Check if a pattern is complex enough to require wildmatch instead of simple glob matching
204+
fn must_use_pattern_matching(pattern: &BStr) -> bool {
205+
let asterisk_count = pattern.iter().filter(|&&b| b == b'*').count();
206+
if asterisk_count > 1 {
207+
return true;
175208
}
209+
pattern
210+
.iter()
211+
.any(|&b| b == b'?' || b == b'[' || b == b']' || b == b'\\')
176212
}

gix-refspec/src/parse.rs

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,11 @@ pub(crate) mod function {
116116
*spec = "HEAD".into();
117117
}
118118
}
119-
let (src, src_had_pattern) = validated(src, operation == Operation::Push && dst.is_some())?;
120-
let (dst, dst_had_pattern) = validated(dst, false)?;
121-
if mode != Mode::Negative && src_had_pattern != dst_had_pattern {
119+
let is_one_sided = dst.is_none();
120+
let (src, src_had_pattern) = validated(src, operation == Operation::Push && dst.is_some(), is_one_sided)?;
121+
let (dst, dst_had_pattern) = validated(dst, false, false)?;
122+
// For one-sided refspecs, we don't need to check for pattern balance
123+
if !is_one_sided && mode != Mode::Negative && src_had_pattern != dst_had_pattern {
122124
return Err(Error::PatternUnbalanced);
123125
}
124126

@@ -149,20 +151,31 @@ pub(crate) mod function {
149151
spec.len() >= gix_hash::Kind::shortest().len_in_hex() && spec.iter().all(u8::is_ascii_hexdigit)
150152
}
151153

152-
fn validated(spec: Option<&BStr>, allow_revspecs: bool) -> Result<(Option<&BStr>, bool), Error> {
154+
fn validated(
155+
spec: Option<&BStr>,
156+
allow_revspecs: bool,
157+
is_one_sided: bool,
158+
) -> Result<(Option<&BStr>, bool), Error> {
153159
match spec {
154160
Some(spec) => {
155161
let glob_count = spec.iter().filter(|b| **b == b'*').take(2).count();
156162
if glob_count > 1 {
157-
return Err(Error::PatternUnsupported { pattern: spec.into() });
163+
// For one-sided refspecs, allow any number of globs without validation
164+
if !is_one_sided {
165+
return Err(Error::PatternUnsupported { pattern: spec.into() });
166+
}
158167
}
159-
let has_globs = glob_count == 1;
168+
// Check if there are any globs (one or more asterisks)
169+
let has_globs = glob_count > 0;
160170
if has_globs {
161-
let mut buf = smallvec::SmallVec::<[u8; 256]>::with_capacity(spec.len());
162-
buf.extend_from_slice(spec);
163-
let glob_pos = buf.find_byte(b'*').expect("glob present");
164-
buf[glob_pos] = b'a';
165-
gix_validate::reference::name_partial(buf.as_bstr())?;
171+
// For one-sided refspecs, skip validation of glob patterns
172+
if !is_one_sided {
173+
let mut buf = smallvec::SmallVec::<[u8; 256]>::with_capacity(spec.len());
174+
buf.extend_from_slice(spec);
175+
let glob_pos = buf.find_byte(b'*').expect("glob present");
176+
buf[glob_pos] = b'a';
177+
gix_validate::reference::name_partial(buf.as_bstr())?;
178+
}
166179
} else {
167180
gix_validate::reference::name_partial(spec)
168181
.map_err(Error::from)

gix-refspec/tests/refspec/match_group.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,160 @@ mod multiple {
184184
);
185185
}
186186
}
187+
188+
mod complex_globs {
189+
use bstr::BString;
190+
use gix_hash::ObjectId;
191+
use gix_refspec::{parse::Operation, MatchGroup};
192+
193+
#[test]
194+
fn one_sided_complex_glob_patterns_can_be_parsed() {
195+
// The key change is that complex glob patterns with multiple asterisks
196+
// can now be parsed for one-sided refspecs
197+
let spec = gix_refspec::parse("refs/*/foo/*".into(), Operation::Fetch);
198+
assert!(spec.is_ok(), "Should parse complex glob pattern for one-sided refspec");
199+
200+
let spec = gix_refspec::parse("refs/*/*/bar".into(), Operation::Fetch);
201+
assert!(
202+
spec.is_ok(),
203+
"Should parse complex glob pattern with multiple asterisks"
204+
);
205+
206+
let spec = gix_refspec::parse("refs/heads/[a-z.]/release/*".into(), Operation::Fetch);
207+
assert!(spec.is_ok(), "Should parse complex glob pattern");
208+
209+
// Two-sided refspecs with multiple asterisks should still fail
210+
let spec = gix_refspec::parse("refs/*/foo/*:refs/remotes/*".into(), Operation::Fetch);
211+
assert!(spec.is_err(), "Two-sided refspecs with multiple asterisks should fail");
212+
}
213+
214+
#[test]
215+
fn one_sided_simple_glob_patterns_match() {
216+
// Test that simple glob patterns (one asterisk) work correctly with matching
217+
let refs = [
218+
new_ref("refs/heads/feature/foo", "1111111111111111111111111111111111111111"),
219+
new_ref("refs/heads/bugfix/bar", "2222222222222222222222222222222222222222"),
220+
new_ref("refs/tags/v1.0", "3333333333333333333333333333333333333333"),
221+
new_ref("refs/pull/123", "4444444444444444444444444444444444444444"),
222+
];
223+
let items: Vec<_> = refs.iter().map(|r| r.to_item()).collect();
224+
225+
// Test: refs/heads/* should match all refs under refs/heads/
226+
let spec = gix_refspec::parse("refs/heads/*".into(), Operation::Fetch).unwrap();
227+
let group = MatchGroup::from_fetch_specs([spec]);
228+
let outcome = group.match_lhs(items.iter().copied());
229+
230+
insta::assert_debug_snapshot!(outcome.mappings, @r#"
231+
[
232+
Mapping {
233+
item_index: Some(
234+
0,
235+
),
236+
lhs: FullName(
237+
"refs/heads/feature/foo",
238+
),
239+
rhs: None,
240+
spec_index: 0,
241+
},
242+
Mapping {
243+
item_index: Some(
244+
1,
245+
),
246+
lhs: FullName(
247+
"refs/heads/bugfix/bar",
248+
),
249+
rhs: None,
250+
spec_index: 0,
251+
},
252+
]
253+
"#);
254+
255+
// Test: refs/tags/* should match all refs under refs/tags/
256+
let items: Vec<_> = refs.iter().map(|r| r.to_item()).collect();
257+
let spec = gix_refspec::parse("refs/tags/v[0-9]*".into(), Operation::Fetch).unwrap();
258+
let group = MatchGroup::from_fetch_specs([spec]);
259+
let outcome = group.match_lhs(items.iter().copied());
260+
261+
insta::assert_debug_snapshot!(outcome.mappings, @r#"
262+
[
263+
Mapping {
264+
item_index: Some(
265+
2,
266+
),
267+
lhs: FullName(
268+
"refs/tags/v1.0",
269+
),
270+
rhs: None,
271+
spec_index: 0,
272+
},
273+
]
274+
"#);
275+
}
276+
277+
#[test]
278+
fn one_sided_glob_with_suffix_matches() {
279+
// Test that glob patterns with suffix work correctly
280+
let refs = [
281+
new_ref("refs/heads/feature", "1111111111111111111111111111111111111111"),
282+
new_ref("refs/heads/feat", "2222222222222222222222222222222222222222"),
283+
new_ref("refs/heads/main", "3333333333333333333333333333333333333333"),
284+
];
285+
let items: Vec<_> = refs.iter().map(|r| r.to_item()).collect();
286+
287+
// Test: refs/heads/feat* should match refs/heads/feature and refs/heads/feat
288+
let spec = gix_refspec::parse("refs/heads/feat*".into(), Operation::Fetch).unwrap();
289+
let group = MatchGroup::from_fetch_specs([spec]);
290+
let outcome = group.match_lhs(items.iter().copied());
291+
let mappings = outcome.mappings;
292+
293+
insta::assert_debug_snapshot!(mappings, @r#"
294+
[
295+
Mapping {
296+
item_index: Some(
297+
0,
298+
),
299+
lhs: FullName(
300+
"refs/heads/feature",
301+
),
302+
rhs: None,
303+
spec_index: 0,
304+
},
305+
Mapping {
306+
item_index: Some(
307+
1,
308+
),
309+
lhs: FullName(
310+
"refs/heads/feat",
311+
),
312+
rhs: None,
313+
spec_index: 0,
314+
},
315+
]
316+
"#);
317+
}
318+
319+
fn new_ref(name: &str, id_hex: &str) -> Ref {
320+
Ref {
321+
name: name.into(),
322+
target: ObjectId::from_hex(id_hex.as_bytes()).unwrap(),
323+
object: None,
324+
}
325+
}
326+
327+
#[derive(Debug, Clone)]
328+
struct Ref {
329+
name: BString,
330+
target: ObjectId,
331+
object: Option<ObjectId>,
332+
}
333+
334+
impl Ref {
335+
fn to_item(&self) -> gix_refspec::match_group::Item<'_> {
336+
gix_refspec::match_group::Item {
337+
full_ref_name: self.name.as_ref(),
338+
target: &self.target,
339+
object: self.object.as_deref(),
340+
}
341+
}
342+
}
343+
}

gix-refspec/tests/refspec/parse/fetch.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,42 @@ fn ampersand_on_left_hand_side_is_head() {
174174
fn empty_refspec_is_enough_for_fetching_head_into_fetchhead() {
175175
assert_parse("", Instruction::Fetch(Fetch::Only { src: b("HEAD") }));
176176
}
177+
178+
#[test]
179+
fn complex_glob_patterns_are_allowed_in_one_sided_refspecs() {
180+
// Complex patterns with multiple asterisks should work for one-sided refspecs
181+
assert_parse(
182+
"refs/*/foo/*",
183+
Instruction::Fetch(Fetch::Only { src: b("refs/*/foo/*") }),
184+
);
185+
186+
assert_parse(
187+
"+refs/heads/*/release/*",
188+
Instruction::Fetch(Fetch::Only {
189+
src: b("refs/heads/*/release/*"),
190+
}),
191+
);
192+
193+
// Even more complex patterns
194+
assert_parse(
195+
"refs/*/*/branch",
196+
Instruction::Fetch(Fetch::Only {
197+
src: b("refs/*/*/branch"),
198+
}),
199+
);
200+
}
201+
202+
#[test]
203+
fn complex_glob_patterns_still_fail_for_two_sided_refspecs() {
204+
// Two-sided refspecs with complex patterns (multiple asterisks) should still fail
205+
for spec in [
206+
"refs/*/foo/*:refs/remotes/origin/*",
207+
"refs/*/*:refs/remotes/*",
208+
"a/*/c/*:b/*",
209+
] {
210+
assert!(matches!(
211+
try_parse(spec, Operation::Fetch).unwrap_err(),
212+
Error::PatternUnsupported { .. }
213+
));
214+
}
215+
}

0 commit comments

Comments
 (0)