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