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    /// Sparse-checkout pattern lines in file order (for Git-style inclusion checks).
24    #[must_use]
25    pub fn lines(&self) -> &[String] {
26        &self.lines
27    }
28
29    /// Parse a sparse-checkout file into ordered patterns (non-cone mode).
30    #[must_use]
31    pub fn parse(content: &str) -> Self {
32        let lines = content
33            .lines()
34            .map(str::trim)
35            .filter(|l| !l.is_empty() && !l.starts_with('#'))
36            .map(String::from)
37            .collect();
38        Self { lines }
39    }
40
41    /// Returns true if `path` is included after applying ordered negated patterns.
42    #[must_use]
43    pub fn path_included(&self, path: &str) -> bool {
44        let mut included = false;
45        for raw in &self.lines {
46            let (negated, core) = match raw.strip_prefix('!') {
47                Some(rest) => (true, rest),
48                None => (false, raw.as_str()),
49            };
50            let core = core.trim();
51            if core.is_empty() || core.starts_with('#') {
52                continue;
53            }
54            if non_cone_line_matches(core, path) {
55                included = !negated;
56            }
57        }
58        included
59    }
60}
61
62/// Mirror Git `dup_and_filter_pattern` (dir.c): drop escape backslashes (once each), then
63/// if the result ends in `/*` truncate that trailing `/*`. Operates on a leading-`/` pattern.
64fn dup_and_filter_pattern(pattern: &str) -> String {
65    let bytes = pattern.as_bytes();
66    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
67    let mut i = 0usize;
68    while i < bytes.len() {
69        if bytes[i] == b'\\' {
70            i += 1;
71            if i >= bytes.len() {
72                break;
73            }
74        }
75        out.push(bytes[i]);
76        i += 1;
77    }
78    if out.len() > 2 && out[out.len() - 1] == b'*' && out[out.len() - 2] == b'/' {
79        out.truncate(out.len() - 2);
80    }
81    String::from_utf8_lossy(&out).into_owned()
82}
83
84fn glob_special_unescaped(name: &[u8]) -> bool {
85    let mut i = 0usize;
86    while i < name.len() {
87        if name[i] == b'\\' {
88            i += 2;
89            continue;
90        }
91        if matches!(name[i], b'*' | b'?' | b'[') {
92            return true;
93        }
94        i += 1;
95    }
96    false
97}
98
99fn sparse_glob_match_star_crosses_slash(pattern: &[u8], text: &[u8]) -> bool {
100    // For bracket classes / escapes, defer to full wildmatch with pathname disabled,
101    // which keeps sparse non-cone semantics where `*` may span `/`.
102    if pattern.contains(&b'[') || pattern.contains(&b'\\') {
103        return wildmatch(pattern, text, 0);
104    }
105    let (mut pi, mut ti) = (0usize, 0usize);
106    let (mut star_p, mut star_t) = (usize::MAX, 0usize);
107    while ti < text.len() {
108        if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == text[ti]) {
109            pi += 1;
110            ti += 1;
111        } else if pi < pattern.len() && pattern[pi] == b'*' {
112            star_p = pi;
113            star_t = ti;
114            pi += 1;
115        } else if star_p != usize::MAX {
116            pi = star_p + 1;
117            star_t += 1;
118            ti = star_t;
119        } else {
120            return false;
121        }
122    }
123    while pi < pattern.len() && pattern[pi] == b'*' {
124        pi += 1;
125    }
126    pi == pattern.len()
127}
128
129/// Same semantics as Git's plumbing for sparse-checkout file lines (`*` matches across `/`).
130fn sparse_pattern_matches_git_non_cone(pattern: &str, path: &str) -> bool {
131    let pat = pattern.trim();
132    if pat.is_empty() {
133        return false;
134    }
135
136    let anchored = pat.starts_with('/');
137    let pat = pat.trim_start_matches('/');
138
139    if let Some(dir) = pat.strip_suffix('/') {
140        if anchored && dir == "*" {
141            return path.contains('/');
142        }
143        if anchored {
144            return path == dir || path.starts_with(&format!("{dir}/"));
145        }
146        return path == dir
147            || path.starts_with(&format!("{dir}/"))
148            || path.split('/').any(|component| component == dir);
149    }
150
151    if anchored {
152        return sparse_glob_match_star_crosses_slash(pat.as_bytes(), path.as_bytes());
153    }
154    sparse_glob_match_star_crosses_slash(pat.as_bytes(), path.as_bytes())
155        || path.rsplit('/').next().is_some_and(|base| {
156            sparse_glob_match_star_crosses_slash(pat.as_bytes(), base.as_bytes())
157        })
158}
159
160fn non_cone_line_matches(pattern: &str, path: &str) -> bool {
161    sparse_pattern_matches_git_non_cone(pattern, path)
162}
163
164/// Cone-mode sparse state: keys use a leading `/` (Git's internal form).
165#[derive(Debug, Clone, Default)]
166pub struct ConePatterns {
167    pub full_cone: bool,
168    pub recursive_slash: BTreeSet<String>,
169    pub parent_slash: BTreeSet<String>,
170}
171
172#[derive(Clone, Copy, PartialEq, Eq)]
173enum ConeMatch {
174    Undecided,
175    Matched,
176    MatchedRecursive,
177    NotMatched,
178}
179
180impl ConePatterns {
181    /// Parse sparse-checkout lines in cone mode. On structural failure returns `None` and
182    /// callers should fall back to non-cone matching (and may print `warnings`).
183    #[must_use]
184    pub fn try_parse_with_warnings(content: &str, warnings: &mut Vec<String>) -> Option<Self> {
185        let lines: Vec<&str> = content
186            .lines()
187            .map(str::trim)
188            .filter(|l| !l.is_empty() && !l.starts_with('#'))
189            .collect();
190
191        let mut full_cone = false;
192        let mut recursive: BTreeSet<String> = BTreeSet::new();
193        let mut parents: BTreeSet<String> = BTreeSet::new();
194
195        for line in lines {
196            let (negated, rest) = if let Some(r) = line.strip_prefix('!') {
197                (true, r)
198            } else {
199                (false, line)
200            };
201
202            // Git `dir.c:add_pattern_to_hashsets`: negative root-all with directory flag clears
203            // full_cone (`!/*` or expanded form `!/*/`); `/*` sets full_cone.
204            if negated && (rest == "/*" || rest == "/*/") {
205                full_cone = false;
206                continue;
207            }
208            if !negated && rest == "/*" {
209                full_cone = true;
210                continue;
211            }
212
213            // Git `add_pattern_to_hashsets`: a MUSTBEDIR pattern whose stored form (trailing
214            // dir-slash dropped) ends in `/*` is the "exclude immediate children" rule. For
215            // `!/foo/bar/*/` the stored pattern is `!/foo/bar/*`; `dup_and_filter_pattern`
216            // truncates the trailing `/*` to `/foo/bar`, which must already be recursive.
217            // This also accepts multi-segment cone parents (e.g. `!/deep/deeper1/*/`) since
218            // `/` is not a glob-special character.
219            let stored = rest.strip_suffix('/').unwrap_or(rest);
220            if stored.starts_with('/')
221                && stored.len() > 2
222                && stored.ends_with("/*")
223                && !stored.ends_with("\\*")
224            {
225                if !negated {
226                    warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
227                    warnings.push("warning: disabling cone pattern matching".to_string());
228                    return None;
229                }
230                let key = dup_and_filter_pattern(stored);
231                if !recursive.contains(&key) {
232                    warnings.push(format!("warning: unrecognized negative pattern: '{rest}'"));
233                    warnings.push("warning: disabling cone pattern matching".to_string());
234                    return None;
235                }
236                recursive.remove(&key);
237                parents.insert(key);
238                continue;
239            }
240
241            if negated {
242                warnings.push(format!("warning: unrecognized negative pattern: '{rest}'"));
243                warnings.push("warning: disabling cone pattern matching".to_string());
244                return None;
245            }
246
247            if rest == "/*" {
248                continue;
249            }
250
251            if !rest.starts_with('/') {
252                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
253                warnings.push("warning: disabling cone pattern matching".to_string());
254                return None;
255            }
256            if rest.contains("**") {
257                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
258                warnings.push("warning: disabling cone pattern matching".to_string());
259                return None;
260            }
261            if rest.len() < 2 {
262                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
263                warnings.push("warning: disabling cone pattern matching".to_string());
264                return None;
265            }
266
267            let must_be_dir = rest.ends_with('/');
268            let body = rest[1..].trim_end_matches('/');
269            if body.is_empty() {
270                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
271                warnings.push("warning: disabling cone pattern matching".to_string());
272                return None;
273            }
274            if !must_be_dir {
275                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
276                warnings.push("warning: disabling cone pattern matching".to_string());
277                return None;
278            }
279            if glob_special_unescaped(body.as_bytes()) {
280                warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
281                warnings.push("warning: disabling cone pattern matching".to_string());
282                return None;
283            }
284
285            // Git applies `dup_and_filter_pattern` before hashing: escape backslashes are
286            // removed and a trailing `/*` is truncated. So `/foo/\*/` collapses to `/foo`,
287            // which lets the duplicate-with-parent check fire (t1091 malformed cone patterns).
288            let key = dup_and_filter_pattern(&format!("/{body}"));
289            if parents.contains(&key) {
290                warnings.push(format!(
291                    "warning: your sparse-checkout file may have issues: pattern '{rest}' is repeated"
292                ));
293                warnings.push("warning: disabling cone pattern matching".to_string());
294                return None;
295            }
296            recursive.insert(key.clone());
297            let key_body = key.trim_start_matches('/');
298            let parts: Vec<&str> = key_body.split('/').collect();
299            for i in 1..parts.len() {
300                let prefix = parts[..i].join("/");
301                parents.insert(format!("/{prefix}"));
302            }
303        }
304
305        Some(ConePatterns {
306            full_cone,
307            recursive_slash: recursive,
308            parent_slash: parents,
309        })
310    }
311
312    #[must_use]
313    pub fn try_parse(content: &str) -> Option<Self> {
314        let mut w = Vec::new();
315        Self::try_parse_with_warnings(content, &mut w)
316    }
317
318    fn recursive_contains_parent(path: &str, recursive: &BTreeSet<String>) -> bool {
319        let mut buf = String::from("/");
320        buf.push_str(path);
321        let mut slash_pos = buf.rfind('/');
322        while let Some(pos) = slash_pos {
323            if pos == 0 {
324                break;
325            }
326            buf.truncate(pos);
327            if recursive.contains(&buf) {
328                return true;
329            }
330            slash_pos = buf.rfind('/');
331        }
332        false
333    }
334
335    /// Git `path_matches_pattern_list` for cone mode (`pathname` has no leading slash).
336    fn path_matches_pattern_list(&self, pathname: &str) -> ConeMatch {
337        if self.full_cone {
338            return ConeMatch::Matched;
339        }
340
341        let mut parent_pathname = String::with_capacity(pathname.len() + 2);
342        parent_pathname.push('/');
343        parent_pathname.push_str(pathname);
344
345        let slash_pos = if parent_pathname.ends_with('/') {
346            let sp = parent_pathname.len() - 1;
347            parent_pathname.push('-');
348            sp
349        } else {
350            parent_pathname.rfind('/').unwrap_or(0)
351        };
352
353        if self.recursive_slash.contains(&parent_pathname) {
354            return ConeMatch::MatchedRecursive;
355        }
356
357        if slash_pos == 0 {
358            return ConeMatch::Matched;
359        }
360
361        let parent_key = parent_pathname[..slash_pos].to_string();
362        if self.parent_slash.contains(&parent_key) {
363            return ConeMatch::Matched;
364        }
365
366        if Self::recursive_contains_parent(pathname, &self.recursive_slash) {
367            return ConeMatch::MatchedRecursive;
368        }
369
370        ConeMatch::NotMatched
371    }
372
373    /// Whether `path` (repository-relative, no leading slash) is inside the cone.
374    #[must_use]
375    pub fn path_included(&self, path: &str) -> bool {
376        if path.is_empty() {
377            return true;
378        }
379
380        let bytes = path.as_bytes();
381        let mut end = bytes.len();
382        let mut match_result = ConeMatch::Undecided;
383
384        while end > 0 && match_result == ConeMatch::Undecided {
385            let slice = path.get(..end).unwrap_or("");
386            match_result = self.path_matches_pattern_list(slice);
387
388            let mut slash = end.saturating_sub(1);
389            while slash > 0 && bytes[slash] != b'/' {
390                slash -= 1;
391            }
392            end = if bytes.get(slash) == Some(&b'/') {
393                slash
394            } else {
395                0
396            };
397        }
398
399        matches!(
400            match_result,
401            ConeMatch::Matched | ConeMatch::MatchedRecursive
402        )
403    }
404}
405
406/// Load sparse-checkout file; returns `(cone_parse_ok, cone, non_cone)`.
407#[must_use]
408pub fn load_sparse_checkout(
409    git_dir: &std::path::Path,
410    cone_config: bool,
411) -> (bool, Option<ConePatterns>, NonConePatterns) {
412    let mut w = Vec::new();
413    load_sparse_checkout_with_warnings(git_dir, cone_config, &mut w)
414}
415
416/// Like [`load_sparse_checkout`] but appends cone-parse warnings (for stderr).
417pub fn load_sparse_checkout_with_warnings(
418    git_dir: &std::path::Path,
419    cone_config: bool,
420    warnings: &mut Vec<String>,
421) -> (bool, Option<ConePatterns>, NonConePatterns) {
422    let path = git_dir.join("info").join("sparse-checkout");
423    let Ok(content) = std::fs::read_to_string(&path) else {
424        return (false, None, NonConePatterns { lines: Vec::new() });
425    };
426    let non_cone = NonConePatterns::parse(&content);
427    if !cone_config {
428        return (false, None, non_cone);
429    }
430    match ConePatterns::try_parse_with_warnings(&content, warnings) {
431        Some(cone) => (true, Some(cone), non_cone),
432        None => (false, None, non_cone),
433    }
434}
435
436/// If `path` is included in the sparse checkout.
437#[must_use]
438pub fn path_in_sparse_checkout(
439    path: &str,
440    cone_config: bool,
441    cone: Option<&ConePatterns>,
442    non_cone: &NonConePatterns,
443    work_tree: Option<&std::path::Path>,
444) -> bool {
445    if cone_config {
446        if let Some(c) = cone {
447            return c.path_included(path);
448        }
449    }
450    crate::ignore::path_in_sparse_checkout(path, non_cone.lines(), work_tree)
451}
452
453/// Apply sparse-checkout rules to `index`: stage-0 entries get `skip-worktree` when excluded.
454///
455/// Matches Git's sparse-checkout application used after building a new index from a tree
456/// (`read-tree`, branch checkout, fast-forward merge). When `core.sparseCheckout` is false or
457/// the sparse-checkout file is missing, this is a no-op.
458///
459/// # Parameters
460///
461/// - `git_dir` — repository git directory (reads `config` and `info/sparse-checkout`).
462/// - `index` — index to update in place; bumped to version 3 when any entry is marked skip-worktree.
463/// - `skip_sparse_checkout` — when true (e.g. `read-tree --no-sparse-checkout`), do not set
464///   `skip-worktree` bits even if sparse checkout is enabled.
465pub fn apply_sparse_checkout_skip_worktree(
466    git_dir: &std::path::Path,
467    work_tree: Option<&std::path::Path>,
468    index: &mut crate::index::Index,
469    skip_sparse_checkout: bool,
470) {
471    if skip_sparse_checkout {
472        return;
473    }
474
475    let config = crate::config::ConfigSet::load(Some(git_dir), true)
476        .unwrap_or_else(|_| crate::config::ConfigSet::new());
477    let sparse_enabled = config
478        .get_bool("core.sparsecheckout")
479        .and_then(|r| r.ok())
480        .unwrap_or(false);
481
482    if !sparse_enabled {
483        return;
484    }
485
486    // Git default for core.sparseCheckoutCone is false (environment.c zero-init). When the key
487    // is absent, treat the sparse file as non-cone so we do not try to cone-parse a legitimate
488    // non-cone file (e.g. a bare `sub` pattern) and emit spurious "disabling cone pattern
489    // matching" warnings on every index update (t1011 subtest 21).
490    let cone_config = config
491        .get_bool("core.sparsecheckoutcone")
492        .and_then(|r| r.ok())
493        .unwrap_or(false);
494
495    let mut warnings = Vec::new();
496    let (_cone_ok, _cone_loaded, non_cone) =
497        load_sparse_checkout_with_warnings(git_dir, cone_config, &mut warnings);
498    for line in warnings {
499        eprintln!("{line}");
500    }
501
502    let sparse_path = git_dir.join("info").join("sparse-checkout");
503    let file_content = std::fs::read_to_string(&sparse_path).unwrap_or_default();
504    let sparse_lines = parse_sparse_checkout_file(&file_content);
505
506    // Use silent cone parsing here: non-cone files like `sub` are normal and should not emit
507    // "disabling cone pattern matching" on every index update (t1011 checkout noise).
508    let cone_struct = if cone_config {
509        ConePatterns::try_parse(&file_content)
510    } else {
511        None
512    };
513    let effective_cone = cone_config && cone_struct.is_some();
514
515    // Git: an on-disk sparse-checkout file with no effective patterns (e.g. a file that only
516    // contains blank lines) still enables sparse mode and excludes every path (`pl->nr == 0`
517    // yields UNDECIDED → rejected at repo root in `path_in_sparse_checkout_1`).
518    let sparse_file_exists = sparse_path.is_file();
519    let exclude_all = sparse_file_exists && sparse_lines.is_empty();
520
521    let mut any_skip = false;
522    for entry in &mut index.entries {
523        if entry.stage() != 0 {
524            continue;
525        }
526        let path_str = String::from_utf8_lossy(&entry.path);
527        let included = if exclude_all {
528            false
529        } else if effective_cone {
530            path_in_sparse_checkout(
531                path_str.as_ref(),
532                true,
533                cone_struct.as_ref(),
534                &non_cone,
535                work_tree,
536            )
537        } else {
538            crate::ignore::path_in_sparse_checkout(path_str.as_ref(), non_cone.lines(), work_tree)
539        };
540        entry.set_skip_worktree(!included);
541        if !included {
542            any_skip = true;
543        }
544    }
545
546    if any_skip && index.version < 3 {
547        index.version = 3;
548    }
549}
550
551/// Longest common prefix of `path1` and `path2` that ends at a `/` (Git `max_common_dir_prefix`).
552fn max_common_dir_prefix(path1: &str, path2: &str) -> usize {
553    let b1 = path1.as_bytes();
554    let b2 = path2.as_bytes();
555    let mut common_prefix = 0usize;
556    let mut i = 0usize;
557    while i < b1.len() && i < b2.len() {
558        if b1[i] != b2[i] {
559            break;
560        }
561        if b1[i] == b'/' {
562            common_prefix = i + 1;
563        }
564        i += 1;
565    }
566    common_prefix
567}
568
569struct PathFoundData {
570    /// Cached path prefix that does not exist, always ending with `/` when non-empty.
571    dir: String,
572}
573
574/// Whether `path` names an existing file or symlink (Git `path_found` in `sparse-index.c`).
575fn path_found(path: &str, data: &mut PathFoundData) -> bool {
576    let pb = path.as_bytes();
577    let db = data.dir.as_bytes();
578    if !db.is_empty() && pb.len() >= db.len() && pb[..db.len()] == *db {
579        return false;
580    }
581
582    if std::fs::symlink_metadata(std::path::Path::new(path)).is_ok() {
583        return true;
584    }
585
586    let common_prefix = max_common_dir_prefix(path, &data.dir);
587    data.dir.truncate(common_prefix);
588
589    loop {
590        let rest = &path[data.dir.len()..];
591        if let Some(rel_slash) = rest.find('/') {
592            data.dir.push_str(&rest[..=rel_slash]);
593            if std::fs::symlink_metadata(std::path::Path::new(&data.dir)).is_err() {
594                return false;
595            }
596        } else {
597            data.dir.push_str(rest);
598            data.dir.push('/');
599            break;
600        }
601    }
602    false
603}
604
605/// Clear `skip-worktree` on index entries whose paths exist in the work tree when sparse checkout
606/// is enabled, unless `sparse.expectFilesOutsideOfPatterns` is true.
607///
608/// Matches Git's `clear_skip_worktree_from_present_files` (`sparse-index.c`) for a full
609/// (non-sparse-index) in-memory index.
610pub fn clear_skip_worktree_from_present_files(
611    git_dir: &std::path::Path,
612    work_tree: &std::path::Path,
613    index: &mut crate::index::Index,
614) {
615    let config = crate::config::ConfigSet::load(Some(git_dir), true)
616        .unwrap_or_else(|_| crate::config::ConfigSet::new());
617    let sparse_enabled = config
618        .get_bool("core.sparsecheckout")
619        .and_then(|r| r.ok())
620        .unwrap_or(false);
621    if !sparse_enabled {
622        return;
623    }
624    if config
625        .get_bool("sparse.expectfilesoutsideofpatterns")
626        .and_then(|r| r.ok())
627        .unwrap_or(false)
628    {
629        return;
630    }
631
632    let mut found = PathFoundData { dir: String::new() };
633    for entry in &mut index.entries {
634        if entry.stage() != 0 || !entry.skip_worktree() {
635            continue;
636        }
637        // With assume-unchanged (CE_VALID), Git keeps skip-worktree for `git grep` semantics
638        // (t7817: present file + both bits still excluded from work-tree index grep).
639        if entry.assume_unchanged() {
640            continue;
641        }
642        let rel = String::from_utf8_lossy(&entry.path);
643        let abs = work_tree.join(rel.as_ref());
644        let abs_str = abs.to_string_lossy().into_owned();
645        if path_found(&abs_str, &mut found) {
646            entry.set_skip_worktree(false);
647        }
648    }
649}
650
651/// Mutable cone sparse state (Git `pattern_list` hashmaps) for building `sparse-checkout` files.
652#[derive(Debug, Clone, Default)]
653pub struct ConeWorkspace {
654    pub recursive_slash: BTreeSet<String>,
655    pub parent_slash: BTreeSet<String>,
656}
657
658impl ConeWorkspace {
659    /// Build from parsed cone file content.
660    #[must_use]
661    pub fn from_cone_patterns(cp: &ConePatterns) -> Self {
662        Self {
663            recursive_slash: cp.recursive_slash.clone(),
664            parent_slash: cp.parent_slash.clone(),
665        }
666    }
667
668    /// Rebuild from a set of repository-relative directory paths (after pruning descendants).
669    #[must_use]
670    pub fn from_directory_list(dirs: &[String]) -> Self {
671        let mut pruned: Vec<String> = dirs
672            .iter()
673            .map(|s| s.trim_start_matches('/').trim_end_matches('/').to_string())
674            .filter(|s| !s.is_empty())
675            .collect();
676        pruned.sort();
677        let mut kept: Vec<String> = Vec::new();
678        for d in pruned {
679            if kept
680                .iter()
681                .any(|p| d.starts_with(p) && d.as_bytes().get(p.len()) == Some(&b'/'))
682            {
683                continue;
684            }
685            kept.retain(|k| !(k.starts_with(&d) && k.as_bytes().get(d.len()) == Some(&b'/')));
686            kept.push(d);
687        }
688        let mut ws = ConeWorkspace::default();
689        for d in kept {
690            ws.insert_directory(&d);
691        }
692        ws
693    }
694
695    /// Insert a repository-relative directory path (no leading slash).
696    pub fn insert_directory(&mut self, rel: &str) {
697        let rel = rel.trim_start_matches('/');
698        let rel = rel.trim_end_matches('/');
699        if rel.is_empty() {
700            return;
701        }
702        let key = format!("/{rel}");
703        if self.parent_slash.contains(&key) {
704            return;
705        }
706        self.recursive_slash.insert(key.clone());
707        let parts: Vec<&str> = rel.split('/').collect();
708        for i in 1..parts.len() {
709            let prefix = parts[..i].join("/");
710            self.parent_slash.insert(format!("/{prefix}"));
711        }
712    }
713
714    fn recursive_contains_parent(path_slash: &str, recursive: &BTreeSet<String>) -> bool {
715        let mut buf = String::from(path_slash);
716        let mut slash_pos = buf.rfind('/');
717        while let Some(pos) = slash_pos {
718            if pos == 0 {
719                break;
720            }
721            buf.truncate(pos);
722            if recursive.contains(&buf) {
723                return true;
724            }
725            slash_pos = buf.rfind('/');
726        }
727        false
728    }
729
730    /// Serialize to `.git/info/sparse-checkout` cone format (includes `/*` and `!/*/` header).
731    #[must_use]
732    pub fn to_sparse_checkout_file(&self) -> String {
733        let mut parent_only: Vec<&String> = self
734            .parent_slash
735            .iter()
736            .filter(|p| {
737                !self.recursive_slash.contains(*p)
738                    && !Self::recursive_contains_parent(p, &self.recursive_slash)
739            })
740            .collect();
741        parent_only.sort();
742
743        let mut out = String::new();
744        out.push_str("/*\n!/*/\n");
745
746        for p in parent_only {
747            let esc = escape_cone_path_component(p);
748            out.push_str(&esc);
749            out.push_str("/\n!");
750            out.push_str(&esc);
751            out.push_str("/*/\n");
752        }
753
754        let mut rec_only: Vec<&String> = self
755            .recursive_slash
756            .iter()
757            .filter(|p| !Self::recursive_contains_parent(p, &self.recursive_slash))
758            .collect();
759        rec_only.sort();
760
761        for p in rec_only {
762            let esc = escape_cone_path_component(p);
763            out.push_str(&esc);
764            out.push_str("/\n");
765        }
766        out
767    }
768
769    /// Directory names for `git sparse-checkout list` in cone mode (no leading slash).
770    #[must_use]
771    pub fn list_cone_directories(&self) -> Vec<String> {
772        let mut v: Vec<String> = self
773            .recursive_slash
774            .iter()
775            .map(|s| s.trim_start_matches('/').to_string())
776            .collect();
777        v.sort();
778        v
779    }
780}
781
782fn escape_cone_path_component(path_with_leading_slash: &str) -> String {
783    let mut out = String::new();
784    for ch in path_with_leading_slash.chars() {
785        if matches!(ch, '*' | '?' | '[' | '\\') {
786            out.push('\\');
787        }
788        out.push(ch);
789    }
790    out
791}
792
793/// Read non-empty, non-comment lines from `.git/info/sparse-checkout`.
794pub fn parse_sparse_checkout_file(content: &str) -> Vec<String> {
795    content
796        .lines()
797        .map(|l| l.trim())
798        .filter(|l| !l.is_empty() && !l.starts_with('#'))
799        .map(String::from)
800        .collect()
801}
802
803/// Returns true when the sparse-checkout file uses Git's expanded cone format
804/// (starts with `/*` then `!/*/`).
805pub fn sparse_checkout_lines_look_like_expanded_cone(lines: &[String]) -> bool {
806    lines.len() >= 2 && lines[0] == "/*" && lines[1] == "!/*/"
807}
808
809/// Parent and recursive directory prefixes (no leading slash, no trailing slash) from an
810/// expanded cone sparse-checkout file, matching Git `write_cone_to_file` layout.
811fn parse_expanded_cone_parent_recursive(lines: &[String]) -> Option<(Vec<String>, Vec<String>)> {
812    if !sparse_checkout_lines_look_like_expanded_cone(lines) {
813        return None;
814    }
815    let mut parents = Vec::new();
816    let mut recursive = Vec::new();
817    let mut i = 2usize;
818    while i + 1 < lines.len() {
819        let a = &lines[i];
820        let b = &lines[i + 1];
821        if !a.starts_with('/') || !a.ends_with('/') || !b.starts_with('!') {
822            break;
823        }
824        let inner_a = a.trim_start_matches('/').trim_end_matches('/');
825        let expected_neg = format!("!/{inner_a}/*/");
826        if b != &expected_neg {
827            break;
828        }
829        // On-disk patterns escape glob/backslash chars; match against the literal directory name.
830        parents.push(unescape_cone_pattern_path(inner_a));
831        i += 2;
832    }
833    while i < lines.len() {
834        let line = &lines[i];
835        if line.starts_with('!') {
836            return None;
837        }
838        if !line.starts_with('/') || !line.ends_with('/') {
839            return None;
840        }
841        let body = line.trim_start_matches('/').trim_end_matches('/');
842        if body.is_empty() {
843            return None;
844        }
845        recursive.push(unescape_cone_pattern_path(body));
846        i += 1;
847    }
848    Some((parents, recursive))
849}
850
851fn path_in_expanded_cone(path: &str, lines: &[String]) -> bool {
852    let Some((parents, recursive)) = parse_expanded_cone_parent_recursive(lines) else {
853        return false;
854    };
855    let raw = path.trim_start_matches('/');
856    let is_directory_path = raw.ends_with('/');
857    let path = raw.trim_end_matches('/');
858
859    if !path.contains('/') {
860        // Top-level: files are always in-cone (`/*`). Directories are in-cone only when
861        // they lead into an expanded parent/recursive rule (Git uses dtype when matching).
862        // Callers pass directories with a trailing slash (e.g. `a/`); files have none.
863        if !is_directory_path {
864            return true;
865        }
866        if parents.is_empty() && recursive.is_empty() {
867            return false;
868        }
869        return parents.iter().any(|p| p == path)
870            || recursive
871                .iter()
872                .any(|r| r == path || r.starts_with(&format!("{path}/")));
873    }
874
875    for r in &recursive {
876        if path == *r || path.starts_with(&format!("{r}/")) {
877            return true;
878        }
879    }
880
881    for p in &parents {
882        let p_slash = format!("{}/", p);
883        if path == *p {
884            return true;
885        }
886        if !path.starts_with(&p_slash) {
887            continue;
888        }
889        let rest = &path[p_slash.len()..];
890        let Some(slash_pos) = rest.find('/') else {
891            // Immediate child `p/name`. Git's cone for a parent emits `/p/` plus `!/p/*/`:
892            // a FILE directly in `p/` matches `/p/` (the `!/p/*/` exclusion only matches
893            // sub-DIRECTORIES, never files), so `sub/d` is in-cone. A directory directly in
894            // `p/` is excluded by `!/p/*/` unless a recursive rule re-includes it
895            // (e.g. `sub/dir` under parent `sub`).
896            if !is_directory_path {
897                return true;
898            }
899            let combined = format!("{}/{}", p, rest);
900            return recursive
901                .iter()
902                .any(|r| r == &combined || r.starts_with(&format!("{combined}/")));
903        };
904        let first = &rest[..slash_pos];
905        let combined = format!("{}/{}", p, first);
906        for r in &recursive {
907            let under_r = path == *r || path.starts_with(&format!("{r}/"));
908            let r_covers = r == &combined || r.starts_with(&format!("{combined}/"));
909            if r_covers && under_r {
910                return true;
911            }
912        }
913    }
914
915    false
916}
917
918/// Cone mode from config combined with on-disk pattern shape.
919///
920/// Git parses the sparse-checkout file in cone mode only when it matches the
921/// expanded template (`/*`, `!/*/`, …). Raw lines like `a` are matched as
922/// non-cone patterns even if `core.sparseCheckoutCone` is true.
923#[must_use]
924pub fn effective_cone_mode_for_sparse_file(cone_config: bool, lines: &[String]) -> bool {
925    cone_config && sparse_checkout_lines_look_like_expanded_cone(lines)
926}
927
928/// Build the on-disk sparse-checkout contents for cone mode, matching
929/// `write_cone_to_file` in Git's `builtin/sparse-checkout.c`.
930///
931/// `dirs` are worktree-relative directory paths as the user typed them (no
932/// leading slash, `/` separators). Empty entries are ignored.
933pub fn build_expanded_cone_sparse_checkout_lines(dirs: &[String]) -> Vec<String> {
934    let mut recursive: BTreeSet<String> = BTreeSet::new();
935    for d in dirs {
936        let t = d.trim().trim_start_matches('/').trim_end_matches('/');
937        if t.is_empty() {
938            continue;
939        }
940        recursive.insert(format!("/{t}"));
941    }
942
943    let mut parents: BTreeSet<String> = BTreeSet::new();
944    for r in &recursive {
945        let mut cur = r.clone();
946        loop {
947            let Some(slash) = cur.rfind('/') else {
948                break;
949            };
950            if slash == 0 {
951                break;
952            }
953            cur.truncate(slash);
954            parents.insert(cur.clone());
955        }
956    }
957
958    let mut out = vec!["/*".to_owned(), "!/*/".to_owned()];
959
960    for p in parents.iter() {
961        if recursive.contains(p) {
962            continue;
963        }
964        if recursive_set_has_strict_ancestor(&recursive, p) {
965            continue;
966        }
967        let esc = escape_cone_pattern_path(p);
968        out.push(format!("{esc}/"));
969        out.push(format!("!{esc}/*/"));
970    }
971
972    for r in recursive.iter() {
973        if recursive_set_has_strict_ancestor(&recursive, r) {
974            continue;
975        }
976        let esc = escape_cone_pattern_path(r);
977        out.push(format!("{esc}/"));
978    }
979
980    out
981}
982
983fn escape_cone_pattern_path(path_with_leading_slash: &str) -> String {
984    // Git's `escaped_pattern` escapes backslashes, `[`, `*`, `?`, `#`; keep
985    // tests (and normal paths) working with a minimal escape pass.
986    let mut out = String::with_capacity(path_with_leading_slash.len() + 8);
987    for ch in path_with_leading_slash.chars() {
988        match ch {
989            '\\' | '[' | '*' | '?' | '#' => {
990                out.push('\\');
991                out.push(ch);
992            }
993            _ => out.push(ch),
994        }
995    }
996    out
997}
998
999/// Inverse of [`escape_cone_pattern_path`]: drop the escaping backslash before a glob/backslash
1000/// character so a cone pattern body becomes the plain directory name.
1001fn unescape_cone_pattern_path(escaped: &str) -> String {
1002    let mut out = String::with_capacity(escaped.len());
1003    let mut chars = escaped.chars().peekable();
1004    while let Some(ch) = chars.next() {
1005        if ch == '\\' {
1006            if let Some(&next) = chars.peek() {
1007                if matches!(next, '\\' | '[' | '*' | '?' | '#') {
1008                    out.push(next);
1009                    chars.next();
1010                    continue;
1011                }
1012            }
1013        }
1014        out.push(ch);
1015    }
1016    out
1017}
1018
1019fn recursive_set_has_strict_ancestor(recursive: &BTreeSet<String>, path: &str) -> bool {
1020    let mut cur = path.to_string();
1021    loop {
1022        let Some(slash) = cur.rfind('/') else {
1023            break;
1024        };
1025        if slash == 0 {
1026            break;
1027        }
1028        cur.truncate(slash);
1029        if recursive.contains(&cur) {
1030            return true;
1031        }
1032    }
1033    false
1034}
1035
1036/// User-facing cone directory names from an expanded on-disk file (`folder1`, not `/folder1/`).
1037///
1038/// Git `sparse-checkout list` prints one directory per line for each `/dir/` + `!/dir/*/` pair
1039/// written by `write_cone_to_file`, not the parent-only prefixes.
1040#[must_use]
1041pub fn parse_expanded_cone_user_directories(lines: &[String]) -> Vec<String> {
1042    if !sparse_checkout_lines_look_like_expanded_cone(lines) {
1043        return Vec::new();
1044    }
1045    let mut i = 2usize;
1046    let mut out = Vec::new();
1047    while i < lines.len() {
1048        let line = &lines[i];
1049        if line.starts_with('!') {
1050            i += 1;
1051            continue;
1052        }
1053        if !line.starts_with('/') || !line.ends_with('/') {
1054            i += 1;
1055            continue;
1056        }
1057        let body = line
1058            .trim_start_matches('/')
1059            .trim_end_matches('/')
1060            .to_string();
1061        let expected_neg = format!("!/{body}/*/");
1062        if i + 1 < lines.len() && lines[i + 1] == expected_neg {
1063            out.push(body);
1064            i += 2;
1065            continue;
1066        }
1067        i += 1;
1068    }
1069    out
1070}
1071
1072/// Parse recursive directory paths from an expanded cone sparse-checkout file
1073/// (for merging on `sparse-checkout add`).
1074pub fn parse_expanded_cone_recursive_dirs(lines: &[String]) -> Vec<String> {
1075    if !sparse_checkout_lines_look_like_expanded_cone(lines) {
1076        return Vec::new();
1077    }
1078    let mut i = 2usize;
1079    let mut out = Vec::new();
1080    while i < lines.len() {
1081        let line = &lines[i];
1082        if line.starts_with('!') {
1083            i += 1;
1084            continue;
1085        }
1086        if !line.ends_with('/') || !line.starts_with('/') {
1087            i += 1;
1088            continue;
1089        }
1090        let trimmed = line.trim_end_matches('/');
1091        let body = trimmed.trim_start_matches('/');
1092        let esc = escape_cone_pattern_path(trimmed);
1093        let expected_neg = format!("!{esc}/*/");
1094        if i + 1 < lines.len() && lines[i + 1] == expected_neg {
1095            i += 2;
1096            continue;
1097        }
1098        // On-disk cone patterns escape glob/backslash characters (`escaped_pattern`); the
1099        // directory name itself (Git's recursive hashmap key) is the unescaped form.
1100        out.push(unescape_cone_pattern_path(body));
1101        i += 1;
1102    }
1103    out
1104}
1105
1106/// Directory paths to merge with new inputs for `git sparse-checkout add` when cone mode is on.
1107///
1108/// Git loads the existing file with `core.sparseCheckoutCone` set, then checks
1109/// `existing.use_cone_patterns` after parsing. When the file has the expanded-cone header (`/*`,
1110/// `!/*/`) but non-cone body lines (e.g. a bare `dir` after `init --no-cone`), the file is not cone
1111/// mode and the merge uses literal pattern lines as directory names — not
1112/// [`parse_expanded_cone_recursive_dirs`], which would skip those lines and wrongly treat the file as
1113/// an empty cone list.
1114#[must_use]
1115pub fn cone_directory_inputs_for_add(content: &str) -> Vec<String> {
1116    let lines: Vec<String> = parse_sparse_checkout_file(content);
1117    if sparse_checkout_lines_look_like_expanded_cone(&lines) {
1118        let recursive = parse_expanded_cone_recursive_dirs(&lines);
1119        if !recursive.is_empty() {
1120            return recursive;
1121        }
1122        if lines.len() == 2 {
1123            return Vec::new();
1124        }
1125        // Header matches expanded cone but body lines are not in expanded form (e.g. bare `dir`
1126        // after `init --no-cone`). Merge uses those literals — do not strip with
1127        // `trim_start_matches('/')` on the whole file (would corrupt `/*`).
1128        return lines[2..]
1129            .iter()
1130            .map(|s| {
1131                s.trim()
1132                    .trim_start_matches('/')
1133                    .trim_end_matches('/')
1134                    .to_string()
1135            })
1136            .filter(|s| !s.is_empty())
1137            .collect();
1138    }
1139    if let Some(cp) = ConePatterns::try_parse(content) {
1140        return ConeWorkspace::from_cone_patterns(&cp).list_cone_directories();
1141    }
1142    lines
1143        .iter()
1144        .map(|s| {
1145            s.trim()
1146                .trim_start_matches('/')
1147                .trim_end_matches('/')
1148                .to_string()
1149        })
1150        .filter(|s| !s.is_empty())
1151        .collect()
1152}
1153
1154/// Returns true when `path` is included in the sparse-checkout definition.
1155///
1156/// Implements parent-directory fallback like Git's `path_in_sparse_checkout`:
1157/// if the full path does not match, successively shorter prefixes (directory
1158/// parents) are tried until one matches or the path is exhausted.
1159///
1160/// `path` must use `/` separators and be relative to the repository root.
1161pub fn path_in_sparse_checkout_patterns(path: &str, patterns: &[String], cone_mode: bool) -> bool {
1162    if path.is_empty() || patterns.is_empty() {
1163        return true;
1164    }
1165
1166    // Git's expanded cone file uses parent + recursive directory rules, not plain gitignore
1167    // wildmatch on each line (see `write_cone_to_file` / `path_matches_pattern_list`).
1168    if sparse_checkout_lines_look_like_expanded_cone(patterns) {
1169        return path_in_expanded_cone(path, patterns);
1170    }
1171
1172    // Prefix-directory rules apply to **raw** cone patterns on disk (e.g. `sub`).
1173    let use_cone_prefix = cone_mode;
1174
1175    let mut end = path.len();
1176    while end > 0 {
1177        if path_matches_sparse_patterns(&path[..end], patterns, use_cone_prefix) {
1178            return true;
1179        }
1180        let Some(slash) = path[..end].rfind('/') else {
1181            break;
1182        };
1183        end = slash;
1184    }
1185    false
1186}
1187
1188/// Like [`path_in_sparse_checkout_patterns`], but only applies when `cone_enabled` is true.
1189///
1190/// When sparse-checkout is not in cone mode, Git treats every path as "in" for
1191/// this check (backward compatibility for file destinations).
1192pub fn path_in_cone_mode_sparse_checkout(
1193    path: &str,
1194    patterns: &[String],
1195    cone_enabled: bool,
1196) -> bool {
1197    if !cone_enabled || patterns.is_empty() {
1198        return true;
1199    }
1200    path_in_sparse_checkout_patterns(path, patterns, true)
1201}
1202
1203/// Returns true when `path` is included, using the same rules as
1204/// `grit sparse-checkout` / `apply_sparse_patterns`.
1205pub fn path_matches_sparse_patterns(path: &str, patterns: &[String], cone_mode: bool) -> bool {
1206    let expanded_cone = sparse_checkout_lines_look_like_expanded_cone(patterns);
1207    if expanded_cone {
1208        return path_in_expanded_cone(path, patterns);
1209    }
1210    // Raw cone mode (`sparse-checkout set --cone sub` writing only `sub`): directory-prefix rules.
1211    // Expanded on-disk cone (`/*`, `!/*/`, `/sub/`, …): use full pattern matching like Git.
1212    let raw_cone_prefix = cone_mode && !expanded_cone;
1213
1214    if raw_cone_prefix {
1215        if !path.contains('/') {
1216            return true;
1217        }
1218
1219        for pattern in patterns {
1220            let prefix = pattern.trim_end_matches('/');
1221            if path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/') {
1222                return true;
1223            }
1224            if path == prefix {
1225                return true;
1226            }
1227        }
1228        return false;
1229    }
1230
1231    let mut included = false;
1232    for raw_pattern in patterns {
1233        let pattern = raw_pattern.trim();
1234        if pattern.is_empty() || pattern.starts_with('#') {
1235            continue;
1236        }
1237
1238        let (negated, core_pattern) = if let Some(rest) = pattern.strip_prefix('!') {
1239            (true, rest)
1240        } else {
1241            (false, pattern)
1242        };
1243        if core_pattern.is_empty() || core_pattern == "/" {
1244            continue;
1245        }
1246
1247        let matches = if let Some(prefix_with_slash) = core_pattern.strip_suffix('/') {
1248            // Directory-only patterns: `/a/` or `a/`.
1249            let inner = prefix_with_slash.trim_start_matches('/');
1250            if inner.is_empty() {
1251                false
1252            } else if inner == "*" {
1253                // `/*/` and `!/*/` in expanded-cone files: match only nested paths (contain `/`),
1254                // not every top-level name (plain `wildmatch("*", …)` would match `sub2`, etc.).
1255                let trimmed = path.trim_end_matches('/');
1256                trimmed.contains('/')
1257            } else if inner.contains('*') || inner.contains('?') || inner.contains('[') {
1258                // e.g. `!/sub/*/` in expanded cone mode
1259                let pat = format!("{prefix_with_slash}/");
1260                let text = format!("/{path}/");
1261                wildmatch(pat.as_bytes(), text.as_bytes(), WM_PATHNAME)
1262            } else {
1263                path == inner || path.starts_with(&format!("{inner}/"))
1264            }
1265        } else if core_pattern.starts_with('/') {
1266            // Leading `/` anchors to repo root (same as gitignore / sparse-checkout).
1267            let text = format!("/{}", path.trim_start_matches('/'));
1268            wildmatch(core_pattern.as_bytes(), text.as_bytes(), WM_PATHNAME)
1269        } else {
1270            wildmatch(core_pattern.as_bytes(), path.as_bytes(), WM_PATHNAME)
1271        };
1272
1273        if matches {
1274            included = !negated;
1275        }
1276    }
1277
1278    included
1279}
1280
1281#[cfg(test)]
1282mod path_in_expanded_cone_tests {
1283    use super::path_in_sparse_checkout_patterns;
1284
1285    #[test]
1286    fn root_only_cone_includes_files_not_top_level_dirs() {
1287        let lines = vec!["/*".to_string(), "!/*/".to_string()];
1288        assert!(path_in_sparse_checkout_patterns("file.1.txt", &lines, true));
1289        assert!(!path_in_sparse_checkout_patterns("a/", &lines, true));
1290        assert!(!path_in_sparse_checkout_patterns("d/", &lines, true));
1291    }
1292
1293    #[test]
1294    fn expanded_cone_with_d_includes_d_tree_not_sibling_a() {
1295        let lines = vec!["/*".to_string(), "!/*/".to_string(), "/d/".to_string()];
1296        assert!(path_in_sparse_checkout_patterns("file.1.txt", &lines, true));
1297        assert!(path_in_sparse_checkout_patterns("d/", &lines, true));
1298        assert!(!path_in_sparse_checkout_patterns("a/", &lines, true));
1299        assert!(path_in_sparse_checkout_patterns(
1300            "d/e/file.1.txt",
1301            &lines,
1302            true
1303        ));
1304    }
1305}
1306
1307#[cfg(test)]
1308mod cone_directory_inputs_for_add_tests {
1309    use super::cone_directory_inputs_for_add;
1310
1311    #[test]
1312    fn expanded_header_with_non_cone_body_preserves_literal_dir() {
1313        let content = "/*\n!/*/\ndir\n";
1314        assert_eq!(
1315            cone_directory_inputs_for_add(content),
1316            vec!["dir".to_string()]
1317        );
1318    }
1319
1320    #[test]
1321    fn pure_expanded_cone_uses_recursive_dirs_only() {
1322        let content = "/*\n!/*/\n/sub/\n";
1323        assert_eq!(
1324            cone_directory_inputs_for_add(content),
1325            vec!["sub".to_string()]
1326        );
1327    }
1328}