Skip to main content

grit_lib/
sparse_checkout.rs

1//! Sparse-checkout pattern parsing and path membership (cone and non-cone).
2//!
3//! Cone-mode parsing and matching follow Git's `add_pattern_to_hashsets` and
4//! `path_matches_pattern_list` closely enough for `read-tree` and plumbing tests.
5
6use std::collections::BTreeSet;
7
8use crate::wildmatch::{wildmatch, WM_PATHNAME};
9
10/// Parsed non-cone sparse-checkout patterns in file order (last match wins).
11#[derive(Debug, Clone)]
12pub struct NonConePatterns {
13    lines: Vec<String>,
14}
15
16impl NonConePatterns {
17    /// Build from already-trimmed pattern lines (non-cone mode).
18    #[must_use]
19    pub fn from_lines(lines: Vec<String>) -> Self {
20        Self { lines }
21    }
22
23    /// Parse a sparse-checkout file into ordered patterns (non-cone mode).
24    #[must_use]
25    pub fn parse(content: &str) -> Self {
26        let lines = content
27            .lines()
28            .map(str::trim)
29            .filter(|l| !l.is_empty() && !l.starts_with('#'))
30            .map(String::from)
31            .collect();
32        Self { lines }
33    }
34
35    /// Returns true if `path` is included after applying ordered negated patterns.
36    #[must_use]
37    pub fn path_included(&self, path: &str) -> bool {
38        let mut included = false;
39        for raw in &self.lines {
40            let (negated, core) = match raw.strip_prefix('!') {
41                Some(rest) => (true, rest),
42                None => (false, raw.as_str()),
43            };
44            let core = core.trim();
45            if core.is_empty() || core.starts_with('#') {
46                continue;
47            }
48            if non_cone_line_matches(core, path) {
49                included = !negated;
50            }
51        }
52        included
53    }
54}
55
56fn glob_special_unescaped(name: &[u8]) -> bool {
57    let mut i = 0usize;
58    while i < name.len() {
59        if name[i] == b'\\' {
60            i += 2;
61            continue;
62        }
63        if matches!(name[i], b'*' | b'?' | b'[') {
64            return true;
65        }
66        i += 1;
67    }
68    false
69}
70
71fn sparse_glob_match_star_crosses_slash(pattern: &[u8], text: &[u8]) -> bool {
72    let (mut pi, mut ti) = (0usize, 0usize);
73    let (mut star_p, mut star_t) = (usize::MAX, 0usize);
74    while ti < text.len() {
75        if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == text[ti]) {
76            pi += 1;
77            ti += 1;
78        } else if pi < pattern.len() && pattern[pi] == b'*' {
79            star_p = pi;
80            star_t = ti;
81            pi += 1;
82        } else if star_p != usize::MAX {
83            pi = star_p + 1;
84            star_t += 1;
85            ti = star_t;
86        } else {
87            return false;
88        }
89    }
90    while pi < pattern.len() && pattern[pi] == b'*' {
91        pi += 1;
92    }
93    pi == pattern.len()
94}
95
96/// Same semantics as Git's plumbing for sparse-checkout file lines (`*` matches across `/`).
97fn sparse_pattern_matches_git_non_cone(pattern: &str, path: &str) -> bool {
98    let pat = pattern.trim();
99    if pat.is_empty() {
100        return false;
101    }
102
103    let anchored = pat.starts_with('/');
104    let pat = pat.trim_start_matches('/');
105
106    if let Some(dir) = pat.strip_suffix('/') {
107        if anchored && dir == "*" {
108            return path.contains('/');
109        }
110        if anchored {
111            return path == dir || path.starts_with(&format!("{dir}/"));
112        }
113        return path == dir
114            || path.starts_with(&format!("{dir}/"))
115            || path.split('/').any(|component| component == dir);
116    }
117
118    if anchored {
119        return sparse_glob_match_star_crosses_slash(pat.as_bytes(), path.as_bytes());
120    }
121    sparse_glob_match_star_crosses_slash(pat.as_bytes(), path.as_bytes())
122        || path.rsplit('/').next().is_some_and(|base| {
123            sparse_glob_match_star_crosses_slash(pat.as_bytes(), base.as_bytes())
124        })
125}
126
127fn non_cone_line_matches(pattern: &str, path: &str) -> bool {
128    sparse_pattern_matches_git_non_cone(pattern, path)
129}
130
131/// Cone-mode sparse state: keys use a leading `/` (Git's internal form).
132#[derive(Debug, Clone, Default)]
133pub struct ConePatterns {
134    pub full_cone: bool,
135    pub recursive_slash: BTreeSet<String>,
136    pub parent_slash: BTreeSet<String>,
137}
138
139#[derive(Clone, Copy, PartialEq, Eq)]
140enum ConeMatch {
141    Undecided,
142    Matched,
143    MatchedRecursive,
144    NotMatched,
145}
146
147impl ConePatterns {
148    /// Parse sparse-checkout lines in cone mode. On structural failure returns `None` and
149    /// callers should fall back to non-cone matching (and may print `warnings`).
150    #[must_use]
151    pub fn try_parse_with_warnings(content: &str, warnings: &mut Vec<String>) -> Option<Self> {
152        let lines: Vec<&str> = content
153            .lines()
154            .map(str::trim)
155            .filter(|l| !l.is_empty() && !l.starts_with('#'))
156            .collect();
157
158        let mut full_cone = false;
159        let mut recursive: BTreeSet<String> = BTreeSet::new();
160        let mut parents: BTreeSet<String> = BTreeSet::new();
161
162        for line in lines {
163            let (negated, rest) = if let Some(r) = line.strip_prefix('!') {
164                (true, r)
165            } else {
166                (false, line)
167            };
168
169            if negated && rest == "/*/" {
170                full_cone = false;
171                continue;
172            }
173            if !negated && rest == "/*" {
174                full_cone = true;
175                continue;
176            }
177
178            if negated && rest.ends_with("/*/") && rest.starts_with('/') && rest.len() > 4 {
179                let inner = &rest[1..rest.len() - 3];
180                if inner.is_empty()
181                    || inner.contains('/')
182                    || glob_special_unescaped(inner.as_bytes())
183                {
184                    warnings.push(format!("warning: unrecognized negative pattern: '{rest}'"));
185                    warnings.push("warning: disabling cone pattern matching".to_string());
186                    return None;
187                }
188                let key = format!("/{inner}");
189                if !recursive.contains(&key) {
190                    warnings.push(format!("warning: unrecognized negative pattern: '{rest}'"));
191                    warnings.push("warning: disabling cone pattern matching".to_string());
192                    return None;
193                }
194                recursive.remove(&key);
195                parents.insert(key);
196                continue;
197            }
198
199            if negated {
200                warnings.push(format!("warning: unrecognized negative pattern: '{rest}'"));
201                warnings.push("warning: disabling cone pattern matching".to_string());
202                return None;
203            }
204
205            if rest == "/*" {
206                continue;
207            }
208
209            if !rest.starts_with('/') {
210                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
211                warnings.push("warning: disabling cone pattern matching".to_string());
212                return None;
213            }
214            if rest.contains("**") {
215                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
216                warnings.push("warning: disabling cone pattern matching".to_string());
217                return None;
218            }
219            if rest.len() < 2 {
220                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
221                warnings.push("warning: disabling cone pattern matching".to_string());
222                return None;
223            }
224
225            let must_be_dir = rest.ends_with('/');
226            let body = rest[1..].trim_end_matches('/');
227            if body.is_empty() {
228                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
229                warnings.push("warning: disabling cone pattern matching".to_string());
230                return None;
231            }
232            if !must_be_dir {
233                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
234                warnings.push("warning: disabling cone pattern matching".to_string());
235                return None;
236            }
237            if glob_special_unescaped(body.as_bytes()) {
238                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
239                warnings.push("warning: disabling cone pattern matching".to_string());
240                return None;
241            }
242
243            let key = format!("/{body}");
244            if parents.contains(&key) {
245                warnings.push(format!(
246                    "warning: your sparse-checkout file may have issues: pattern '{rest}' is repeated"
247                ));
248                warnings.push("warning: disabling cone pattern matching".to_string());
249                return None;
250            }
251            recursive.insert(key.clone());
252            let parts: Vec<&str> = body.split('/').collect();
253            for i in 1..parts.len() {
254                let prefix = parts[..i].join("/");
255                parents.insert(format!("/{prefix}"));
256            }
257        }
258
259        Some(ConePatterns {
260            full_cone,
261            recursive_slash: recursive,
262            parent_slash: parents,
263        })
264    }
265
266    #[must_use]
267    pub fn try_parse(content: &str) -> Option<Self> {
268        let mut w = Vec::new();
269        Self::try_parse_with_warnings(content, &mut w)
270    }
271
272    fn recursive_contains_parent(path: &str, recursive: &BTreeSet<String>) -> bool {
273        let mut buf = String::from("/");
274        buf.push_str(path);
275        let mut slash_pos = buf.rfind('/');
276        while let Some(pos) = slash_pos {
277            if pos == 0 {
278                break;
279            }
280            buf.truncate(pos);
281            if recursive.contains(&buf) {
282                return true;
283            }
284            slash_pos = buf.rfind('/');
285        }
286        false
287    }
288
289    /// Git `path_matches_pattern_list` for cone mode (`pathname` has no leading slash).
290    fn path_matches_pattern_list(&self, pathname: &str) -> ConeMatch {
291        if self.full_cone {
292            return ConeMatch::Matched;
293        }
294
295        let mut parent_pathname = String::with_capacity(pathname.len() + 2);
296        parent_pathname.push('/');
297        parent_pathname.push_str(pathname);
298
299        let slash_pos = if parent_pathname.ends_with('/') {
300            let sp = parent_pathname.len() - 1;
301            parent_pathname.push('-');
302            sp
303        } else {
304            parent_pathname.rfind('/').unwrap_or(0)
305        };
306
307        if self.recursive_slash.contains(&parent_pathname) {
308            return ConeMatch::MatchedRecursive;
309        }
310
311        if slash_pos == 0 {
312            return ConeMatch::Matched;
313        }
314
315        let parent_key = parent_pathname[..slash_pos].to_string();
316        if self.parent_slash.contains(&parent_key) {
317            return ConeMatch::Matched;
318        }
319
320        if Self::recursive_contains_parent(pathname, &self.recursive_slash) {
321            return ConeMatch::MatchedRecursive;
322        }
323
324        ConeMatch::NotMatched
325    }
326
327    /// Whether `path` (repository-relative, no leading slash) is inside the cone.
328    #[must_use]
329    pub fn path_included(&self, path: &str) -> bool {
330        if path.is_empty() {
331            return true;
332        }
333
334        let bytes = path.as_bytes();
335        let mut end = bytes.len();
336        let mut match_result = ConeMatch::Undecided;
337
338        while end > 0 && match_result == ConeMatch::Undecided {
339            let slice = path.get(..end).unwrap_or("");
340            match_result = self.path_matches_pattern_list(slice);
341
342            let mut slash = end.saturating_sub(1);
343            while slash > 0 && bytes[slash] != b'/' {
344                slash -= 1;
345            }
346            end = if bytes.get(slash) == Some(&b'/') {
347                slash
348            } else {
349                0
350            };
351        }
352
353        matches!(
354            match_result,
355            ConeMatch::Matched | ConeMatch::MatchedRecursive
356        )
357    }
358}
359
360/// Load sparse-checkout file; returns `(cone_parse_ok, cone, non_cone)`.
361#[must_use]
362pub fn load_sparse_checkout(
363    git_dir: &std::path::Path,
364    cone_config: bool,
365) -> (bool, Option<ConePatterns>, NonConePatterns) {
366    let mut w = Vec::new();
367    load_sparse_checkout_with_warnings(git_dir, cone_config, &mut w)
368}
369
370/// Like [`load_sparse_checkout`] but appends cone-parse warnings (for stderr).
371pub fn load_sparse_checkout_with_warnings(
372    git_dir: &std::path::Path,
373    cone_config: bool,
374    warnings: &mut Vec<String>,
375) -> (bool, Option<ConePatterns>, NonConePatterns) {
376    let path = git_dir.join("info").join("sparse-checkout");
377    let Ok(content) = std::fs::read_to_string(&path) else {
378        return (false, None, NonConePatterns { lines: Vec::new() });
379    };
380    let non_cone = NonConePatterns::parse(&content);
381    if !cone_config {
382        return (false, None, non_cone);
383    }
384    match ConePatterns::try_parse_with_warnings(&content, warnings) {
385        Some(cone) => (true, Some(cone), non_cone),
386        None => (false, None, non_cone),
387    }
388}
389
390/// If `path` is included in the sparse checkout.
391#[must_use]
392pub fn path_in_sparse_checkout(
393    path: &str,
394    cone_config: bool,
395    cone: Option<&ConePatterns>,
396    non_cone: &NonConePatterns,
397) -> bool {
398    if cone_config {
399        if let Some(c) = cone {
400            return c.path_included(path);
401        }
402    }
403    non_cone.path_included(path)
404}
405
406/// Mutable cone sparse state (Git `pattern_list` hashmaps) for building `sparse-checkout` files.
407#[derive(Debug, Clone, Default)]
408pub struct ConeWorkspace {
409    pub recursive_slash: BTreeSet<String>,
410    pub parent_slash: BTreeSet<String>,
411}
412
413impl ConeWorkspace {
414    /// Build from parsed cone file content.
415    #[must_use]
416    pub fn from_cone_patterns(cp: &ConePatterns) -> Self {
417        Self {
418            recursive_slash: cp.recursive_slash.clone(),
419            parent_slash: cp.parent_slash.clone(),
420        }
421    }
422
423    /// Rebuild from a set of repository-relative directory paths (after pruning descendants).
424    #[must_use]
425    pub fn from_directory_list(dirs: &[String]) -> Self {
426        let mut pruned: Vec<String> = dirs
427            .iter()
428            .map(|s| s.trim_start_matches('/').trim_end_matches('/').to_string())
429            .filter(|s| !s.is_empty())
430            .collect();
431        pruned.sort();
432        let mut kept: Vec<String> = Vec::new();
433        for d in pruned {
434            if kept
435                .iter()
436                .any(|p| d.starts_with(p) && d.as_bytes().get(p.len()) == Some(&b'/'))
437            {
438                continue;
439            }
440            kept.retain(|k| !(k.starts_with(&d) && k.as_bytes().get(d.len()) == Some(&b'/')));
441            kept.push(d);
442        }
443        let mut ws = ConeWorkspace::default();
444        for d in kept {
445            ws.insert_directory(&d);
446        }
447        ws
448    }
449
450    /// Insert a repository-relative directory path (no leading slash).
451    pub fn insert_directory(&mut self, rel: &str) {
452        let rel = rel.trim_start_matches('/');
453        let rel = rel.trim_end_matches('/');
454        if rel.is_empty() {
455            return;
456        }
457        let key = format!("/{rel}");
458        if self.parent_slash.contains(&key) {
459            return;
460        }
461        self.recursive_slash.insert(key.clone());
462        let parts: Vec<&str> = rel.split('/').collect();
463        for i in 1..parts.len() {
464            let prefix = parts[..i].join("/");
465            self.parent_slash.insert(format!("/{prefix}"));
466        }
467    }
468
469    fn recursive_contains_parent(path_slash: &str, recursive: &BTreeSet<String>) -> bool {
470        let mut buf = String::from(path_slash);
471        let mut slash_pos = buf.rfind('/');
472        while let Some(pos) = slash_pos {
473            if pos == 0 {
474                break;
475            }
476            buf.truncate(pos);
477            if recursive.contains(&buf) {
478                return true;
479            }
480            slash_pos = buf.rfind('/');
481        }
482        false
483    }
484
485    /// Serialize to `.git/info/sparse-checkout` cone format (includes `/*` and `!/*/` header).
486    #[must_use]
487    pub fn to_sparse_checkout_file(&self) -> String {
488        let mut parent_only: Vec<&String> = self
489            .parent_slash
490            .iter()
491            .filter(|p| {
492                !self.recursive_slash.contains(*p)
493                    && !Self::recursive_contains_parent(p, &self.recursive_slash)
494            })
495            .collect();
496        parent_only.sort();
497
498        let mut out = String::new();
499        out.push_str("/*\n!/*/\n");
500
501        for p in parent_only {
502            let esc = escape_cone_path_component(p);
503            out.push_str(&esc);
504            out.push_str("/\n!");
505            out.push_str(&esc);
506            out.push_str("/*/\n");
507        }
508
509        let mut rec_only: Vec<&String> = self
510            .recursive_slash
511            .iter()
512            .filter(|p| !Self::recursive_contains_parent(p, &self.recursive_slash))
513            .collect();
514        rec_only.sort();
515
516        for p in rec_only {
517            let esc = escape_cone_path_component(p);
518            out.push_str(&esc);
519            out.push_str("/\n");
520        }
521        out
522    }
523
524    /// Directory names for `git sparse-checkout list` in cone mode (no leading slash).
525    #[must_use]
526    pub fn list_cone_directories(&self) -> Vec<String> {
527        let mut v: Vec<String> = self
528            .recursive_slash
529            .iter()
530            .map(|s| s.trim_start_matches('/').to_string())
531            .collect();
532        v.sort();
533        v
534    }
535}
536
537fn escape_cone_path_component(path_with_leading_slash: &str) -> String {
538    let mut out = String::new();
539    for ch in path_with_leading_slash.chars() {
540        if matches!(ch, '*' | '?' | '[' | '\\') {
541            out.push('\\');
542        }
543        out.push(ch);
544    }
545    out
546}
547
548/// Read non-empty, non-comment lines from `.git/info/sparse-checkout`.
549pub fn parse_sparse_checkout_file(content: &str) -> Vec<String> {
550    content
551        .lines()
552        .map(|l| l.trim())
553        .filter(|l| !l.is_empty() && !l.starts_with('#'))
554        .map(String::from)
555        .collect()
556}
557
558/// Returns true when the sparse-checkout file uses Git's expanded cone format
559/// (starts with `/*` then `!/*/`).
560pub fn sparse_checkout_lines_look_like_expanded_cone(lines: &[String]) -> bool {
561    lines.len() >= 2 && lines[0] == "/*" && lines[1] == "!/*/"
562}
563
564/// Parent and recursive directory prefixes (no leading slash, no trailing slash) from an
565/// expanded cone sparse-checkout file, matching Git `write_cone_to_file` layout.
566fn parse_expanded_cone_parent_recursive(lines: &[String]) -> Option<(Vec<String>, Vec<String>)> {
567    if !sparse_checkout_lines_look_like_expanded_cone(lines) {
568        return None;
569    }
570    let mut parents = Vec::new();
571    let mut recursive = Vec::new();
572    let mut i = 2usize;
573    while i + 1 < lines.len() {
574        let a = &lines[i];
575        let b = &lines[i + 1];
576        if !a.starts_with('/') || !a.ends_with('/') || !b.starts_with('!') {
577            break;
578        }
579        let inner_a = a.trim_start_matches('/').trim_end_matches('/');
580        let expected_neg = format!("!/{inner_a}/*/");
581        if b != &expected_neg {
582            break;
583        }
584        parents.push(inner_a.to_string());
585        i += 2;
586    }
587    while i < lines.len() {
588        let line = &lines[i];
589        if line.starts_with('!') {
590            return None;
591        }
592        if !line.starts_with('/') || !line.ends_with('/') {
593            return None;
594        }
595        let body = line.trim_start_matches('/').trim_end_matches('/');
596        if body.is_empty() {
597            return None;
598        }
599        recursive.push(body.to_string());
600        i += 1;
601    }
602    Some((parents, recursive))
603}
604
605fn path_in_expanded_cone(path: &str, lines: &[String]) -> bool {
606    let Some((parents, recursive)) = parse_expanded_cone_parent_recursive(lines) else {
607        return false;
608    };
609    let path = path.trim_start_matches('/').trim_end_matches('/');
610
611    if !path.contains('/') {
612        return true;
613    }
614
615    for r in &recursive {
616        if path == *r || path.starts_with(&format!("{r}/")) {
617            return true;
618        }
619    }
620
621    for p in &parents {
622        let p_slash = format!("{}/", p);
623        if path == *p {
624            return true;
625        }
626        if !path.starts_with(&p_slash) {
627            continue;
628        }
629        let rest = &path[p_slash.len()..];
630        let Some(slash_pos) = rest.find('/') else {
631            // Immediate child `p/name`: in-cone only when it leads into a recursive directory
632            // (e.g. `sub/dir` under parent `sub`), not for unrelated files like `sub/d`.
633            let combined = format!("{}/{}", p, rest);
634            return recursive
635                .iter()
636                .any(|r| r == &combined || r.starts_with(&format!("{combined}/")));
637        };
638        let first = &rest[..slash_pos];
639        let combined = format!("{}/{}", p, first);
640        for r in &recursive {
641            let under_r = path == *r || path.starts_with(&format!("{r}/"));
642            let r_covers = r == &combined || r.starts_with(&format!("{combined}/"));
643            if r_covers && under_r {
644                return true;
645            }
646        }
647    }
648
649    false
650}
651
652/// Cone mode from config combined with on-disk pattern shape.
653///
654/// Git parses the sparse-checkout file in cone mode only when it matches the
655/// expanded template (`/*`, `!/*/`, …). Raw lines like `a` are matched as
656/// non-cone patterns even if `core.sparseCheckoutCone` is true.
657#[must_use]
658pub fn effective_cone_mode_for_sparse_file(cone_config: bool, lines: &[String]) -> bool {
659    cone_config && sparse_checkout_lines_look_like_expanded_cone(lines)
660}
661
662/// Build the on-disk sparse-checkout contents for cone mode, matching
663/// `write_cone_to_file` in Git's `builtin/sparse-checkout.c`.
664///
665/// `dirs` are worktree-relative directory paths as the user typed them (no
666/// leading slash, `/` separators). Empty entries are ignored.
667pub fn build_expanded_cone_sparse_checkout_lines(dirs: &[String]) -> Vec<String> {
668    let mut recursive: BTreeSet<String> = BTreeSet::new();
669    for d in dirs {
670        let t = d.trim().trim_start_matches('/').trim_end_matches('/');
671        if t.is_empty() {
672            continue;
673        }
674        recursive.insert(format!("/{t}"));
675    }
676
677    let mut parents: BTreeSet<String> = BTreeSet::new();
678    for r in &recursive {
679        let mut cur = r.clone();
680        loop {
681            let Some(slash) = cur.rfind('/') else {
682                break;
683            };
684            if slash == 0 {
685                break;
686            }
687            cur.truncate(slash);
688            parents.insert(cur.clone());
689        }
690    }
691
692    let mut out = vec!["/*".to_owned(), "!/*/".to_owned()];
693
694    for p in parents.iter() {
695        if recursive.contains(p) {
696            continue;
697        }
698        if recursive_set_has_strict_ancestor(&recursive, p) {
699            continue;
700        }
701        let esc = escape_cone_pattern_path(p);
702        out.push(format!("{esc}/"));
703        out.push(format!("!{esc}/*/"));
704    }
705
706    for r in recursive.iter() {
707        if recursive_set_has_strict_ancestor(&recursive, r) {
708            continue;
709        }
710        let esc = escape_cone_pattern_path(r);
711        out.push(format!("{esc}/"));
712    }
713
714    out
715}
716
717fn escape_cone_pattern_path(path_with_leading_slash: &str) -> String {
718    // Git's `escaped_pattern` escapes backslashes, `[`, `*`, `?`, `#`; keep
719    // tests (and normal paths) working with a minimal escape pass.
720    let mut out = String::with_capacity(path_with_leading_slash.len() + 8);
721    for ch in path_with_leading_slash.chars() {
722        match ch {
723            '\\' | '[' | '*' | '?' | '#' => {
724                out.push('\\');
725                out.push(ch);
726            }
727            _ => out.push(ch),
728        }
729    }
730    out
731}
732
733fn recursive_set_has_strict_ancestor(recursive: &BTreeSet<String>, path: &str) -> bool {
734    let mut cur = path.to_string();
735    loop {
736        let Some(slash) = cur.rfind('/') else {
737            break;
738        };
739        if slash == 0 {
740            break;
741        }
742        cur.truncate(slash);
743        if recursive.contains(&cur) {
744            return true;
745        }
746    }
747    false
748}
749
750/// Parse recursive directory paths from an expanded cone sparse-checkout file
751/// (for merging on `sparse-checkout add`).
752pub fn parse_expanded_cone_recursive_dirs(lines: &[String]) -> Vec<String> {
753    if !sparse_checkout_lines_look_like_expanded_cone(lines) {
754        return Vec::new();
755    }
756    let mut i = 2usize;
757    let mut out = Vec::new();
758    while i < lines.len() {
759        let line = &lines[i];
760        if line.starts_with('!') {
761            i += 1;
762            continue;
763        }
764        if !line.ends_with('/') || !line.starts_with('/') {
765            i += 1;
766            continue;
767        }
768        let trimmed = line.trim_end_matches('/');
769        let body = trimmed.trim_start_matches('/');
770        let esc = escape_cone_pattern_path(trimmed);
771        let expected_neg = format!("!{esc}/*/");
772        if i + 1 < lines.len() && lines[i + 1] == expected_neg {
773            i += 2;
774            continue;
775        }
776        out.push(body.to_owned());
777        i += 1;
778    }
779    out
780}
781
782/// Returns true when `path` is included in the sparse-checkout definition.
783///
784/// Implements parent-directory fallback like Git's `path_in_sparse_checkout`:
785/// if the full path does not match, successively shorter prefixes (directory
786/// parents) are tried until one matches or the path is exhausted.
787///
788/// `path` must use `/` separators and be relative to the repository root.
789pub fn path_in_sparse_checkout_patterns(path: &str, patterns: &[String], cone_mode: bool) -> bool {
790    if path.is_empty() || patterns.is_empty() {
791        return true;
792    }
793
794    // Git's expanded cone file uses parent + recursive directory rules, not plain gitignore
795    // wildmatch on each line (see `write_cone_to_file` / `path_matches_pattern_list`).
796    if sparse_checkout_lines_look_like_expanded_cone(patterns) {
797        return path_in_expanded_cone(path, patterns);
798    }
799
800    // Prefix-directory rules apply to **raw** cone patterns on disk (e.g. `sub`).
801    let use_cone_prefix = cone_mode;
802
803    let mut end = path.len();
804    while end > 0 {
805        if path_matches_sparse_patterns(&path[..end], patterns, use_cone_prefix) {
806            return true;
807        }
808        let Some(slash) = path[..end].rfind('/') else {
809            break;
810        };
811        end = slash;
812    }
813    false
814}
815
816/// Like [`path_in_sparse_checkout_patterns`], but only applies when `cone_enabled` is true.
817///
818/// When sparse-checkout is not in cone mode, Git treats every path as "in" for
819/// this check (backward compatibility for file destinations).
820pub fn path_in_cone_mode_sparse_checkout(
821    path: &str,
822    patterns: &[String],
823    cone_enabled: bool,
824) -> bool {
825    if !cone_enabled || patterns.is_empty() {
826        return true;
827    }
828    path_in_sparse_checkout_patterns(path, patterns, true)
829}
830
831/// Returns true when `path` is included, using the same rules as
832/// `grit sparse-checkout` / `apply_sparse_patterns`.
833pub fn path_matches_sparse_patterns(path: &str, patterns: &[String], cone_mode: bool) -> bool {
834    let expanded_cone = sparse_checkout_lines_look_like_expanded_cone(patterns);
835    if expanded_cone {
836        return path_in_expanded_cone(path, patterns);
837    }
838    // Raw cone mode (`sparse-checkout set --cone sub` writing only `sub`): directory-prefix rules.
839    // Expanded on-disk cone (`/*`, `!/*/`, `/sub/`, …): use full pattern matching like Git.
840    let raw_cone_prefix = cone_mode && !expanded_cone;
841
842    if raw_cone_prefix {
843        if !path.contains('/') {
844            return true;
845        }
846
847        for pattern in patterns {
848            let prefix = pattern.trim_end_matches('/');
849            if path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/') {
850                return true;
851            }
852            if path == prefix {
853                return true;
854            }
855        }
856        return false;
857    }
858
859    let mut included = false;
860    for raw_pattern in patterns {
861        let pattern = raw_pattern.trim();
862        if pattern.is_empty() || pattern.starts_with('#') {
863            continue;
864        }
865
866        let (negated, core_pattern) = if let Some(rest) = pattern.strip_prefix('!') {
867            (true, rest)
868        } else {
869            (false, pattern)
870        };
871        if core_pattern.is_empty() || core_pattern == "/" {
872            continue;
873        }
874
875        let matches = if let Some(prefix_with_slash) = core_pattern.strip_suffix('/') {
876            // Directory-only patterns: `/a/` or `a/`.
877            let inner = prefix_with_slash.trim_start_matches('/');
878            if inner.is_empty() {
879                false
880            } else if negated && core_pattern == "/*/" {
881                // Cone expanded form: after `/*` includes all top-level names, `!/*/` removes
882                // nested paths (two+ segments). Single-segment paths like `a` stay included.
883                let trimmed = path.trim_end_matches('/');
884                trimmed.contains('/')
885            } else if inner.contains('*') || inner.contains('?') || inner.contains('[') {
886                // e.g. `!/sub/*/` in expanded cone mode
887                let pat = format!("{prefix_with_slash}/");
888                let text = format!("/{path}/");
889                wildmatch(pat.as_bytes(), text.as_bytes(), WM_PATHNAME)
890            } else {
891                path == inner || path.starts_with(&format!("{inner}/"))
892            }
893        } else if core_pattern.starts_with('/') {
894            // Leading `/` anchors to repo root (same as gitignore / sparse-checkout).
895            let text = format!("/{}", path.trim_start_matches('/'));
896            wildmatch(core_pattern.as_bytes(), text.as_bytes(), WM_PATHNAME)
897        } else {
898            wildmatch(core_pattern.as_bytes(), path.as_bytes(), WM_PATHNAME)
899        };
900
901        if matches {
902            included = !negated;
903        }
904    }
905
906    included
907}