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    /// `full_cone` field.
168    pub full_cone: bool,
169    /// `recursive_slash` field.
170    pub recursive_slash: BTreeSet<String>,
171    /// `parent_slash` field.
172    pub parent_slash: BTreeSet<String>,
173}
174
175#[derive(Clone, Copy, PartialEq, Eq)]
176enum ConeMatch {
177    Undecided,
178    Matched,
179    MatchedRecursive,
180    NotMatched,
181}
182
183impl ConePatterns {
184    /// Parse sparse-checkout lines in cone mode. On structural failure returns `None` and
185    /// callers should fall back to non-cone matching (and may print `warnings`).
186    #[must_use]
187    pub fn try_parse_with_warnings(content: &str, warnings: &mut Vec<String>) -> Option<Self> {
188        let lines: Vec<&str> = content
189            .lines()
190            .map(str::trim)
191            .filter(|l| !l.is_empty() && !l.starts_with('#'))
192            .collect();
193
194        let mut full_cone = false;
195        let mut recursive: BTreeSet<String> = BTreeSet::new();
196        let mut parents: BTreeSet<String> = BTreeSet::new();
197
198        for line in lines {
199            let (negated, rest) = if let Some(r) = line.strip_prefix('!') {
200                (true, r)
201            } else {
202                (false, line)
203            };
204
205            // Git `dir.c:add_pattern_to_hashsets`: negative root-all with directory flag clears
206            // full_cone (`!/*` or expanded form `!/*/`); `/*` sets full_cone.
207            if negated && (rest == "/*" || rest == "/*/") {
208                full_cone = false;
209                continue;
210            }
211            if !negated && rest == "/*" {
212                full_cone = true;
213                continue;
214            }
215
216            // Git `add_pattern_to_hashsets`: a MUSTBEDIR pattern whose stored form (trailing
217            // dir-slash dropped) ends in `/*` is the "exclude immediate children" rule. For
218            // `!/foo/bar/*/` the stored pattern is `!/foo/bar/*`; `dup_and_filter_pattern`
219            // truncates the trailing `/*` to `/foo/bar`, which must already be recursive.
220            // This also accepts multi-segment cone parents (e.g. `!/deep/deeper1/*/`) since
221            // `/` is not a glob-special character.
222            let stored = rest.strip_suffix('/').unwrap_or(rest);
223            if stored.starts_with('/')
224                && stored.len() > 2
225                && stored.ends_with("/*")
226                && !stored.ends_with("\\*")
227            {
228                if !negated {
229                    warnings.push(crate::diagnostics::warning_line(&format!(
230                        "unrecognized pattern: '{rest}'"
231                    )));
232                    warnings.push(crate::diagnostics::warning_line(
233                        "disabling cone pattern matching",
234                    ));
235                    return None;
236                }
237                let key = dup_and_filter_pattern(stored);
238                if !recursive.contains(&key) {
239                    warnings.push(crate::diagnostics::warning_line(&format!(
240                        "unrecognized negative pattern: '{rest}'"
241                    )));
242                    warnings.push(crate::diagnostics::warning_line(
243                        "disabling cone pattern matching",
244                    ));
245                    return None;
246                }
247                recursive.remove(&key);
248                parents.insert(key);
249                continue;
250            }
251
252            if negated {
253                warnings.push(crate::diagnostics::warning_line(&format!(
254                    "unrecognized negative pattern: '{rest}'"
255                )));
256                warnings.push(crate::diagnostics::warning_line(
257                    "disabling cone pattern matching",
258                ));
259                return None;
260            }
261
262            if rest == "/*" {
263                continue;
264            }
265
266            if !rest.starts_with('/') {
267                warnings.push(crate::diagnostics::warning_line(&format!(
268                    "unrecognized pattern: '{rest}'"
269                )));
270                warnings.push(crate::diagnostics::warning_line(
271                    "disabling cone pattern matching",
272                ));
273                return None;
274            }
275            if rest.contains("**") {
276                warnings.push(crate::diagnostics::warning_line(&format!(
277                    "unrecognized pattern: '{rest}'"
278                )));
279                warnings.push(crate::diagnostics::warning_line(
280                    "disabling cone pattern matching",
281                ));
282                return None;
283            }
284            if rest.len() < 2 {
285                warnings.push(crate::diagnostics::warning_line(&format!(
286                    "unrecognized pattern: '{rest}'"
287                )));
288                warnings.push(crate::diagnostics::warning_line(
289                    "disabling cone pattern matching",
290                ));
291                return None;
292            }
293
294            let must_be_dir = rest.ends_with('/');
295            let body = rest[1..].trim_end_matches('/');
296            if body.is_empty() {
297                warnings.push(crate::diagnostics::warning_line(&format!(
298                    "unrecognized pattern: '{rest}'"
299                )));
300                warnings.push(crate::diagnostics::warning_line(
301                    "disabling cone pattern matching",
302                ));
303                return None;
304            }
305            if !must_be_dir {
306                warnings.push(crate::diagnostics::warning_line(&format!(
307                    "unrecognized pattern: '{rest}'"
308                )));
309                warnings.push(crate::diagnostics::warning_line(
310                    "disabling cone pattern matching",
311                ));
312                return None;
313            }
314            if glob_special_unescaped(body.as_bytes()) {
315                warnings.push(crate::diagnostics::warning_line(&format!(
316                    "unrecognized pattern: '{rest}'"
317                )));
318                warnings.push(crate::diagnostics::warning_line(
319                    "disabling cone pattern matching",
320                ));
321                return None;
322            }
323
324            // Git applies `dup_and_filter_pattern` before hashing: escape backslashes are
325            // removed and a trailing `/*` is truncated. So `/foo/\*/` collapses to `/foo`,
326            // which lets the duplicate-with-parent check fire (t1091 malformed cone patterns).
327            let key = dup_and_filter_pattern(&format!("/{body}"));
328            if parents.contains(&key) {
329                warnings.push(crate::diagnostics::warning_line(&format!(
330                    "your sparse-checkout file may have issues: pattern '{rest}' is repeated"
331                )));
332                warnings.push(crate::diagnostics::warning_line(
333                    "disabling cone pattern matching",
334                ));
335                return None;
336            }
337            recursive.insert(key.clone());
338            let key_body = key.trim_start_matches('/');
339            let parts: Vec<&str> = key_body.split('/').collect();
340            for i in 1..parts.len() {
341                let prefix = parts[..i].join("/");
342                parents.insert(format!("/{prefix}"));
343            }
344        }
345
346        Some(ConePatterns {
347            full_cone,
348            recursive_slash: recursive,
349            parent_slash: parents,
350        })
351    }
352
353    #[must_use]
354    /// `try_parse` associated function.
355    pub fn try_parse(content: &str) -> Option<Self> {
356        let mut w = Vec::new();
357        Self::try_parse_with_warnings(content, &mut w)
358    }
359
360    fn recursive_contains_parent(path: &str, recursive: &BTreeSet<String>) -> bool {
361        let mut buf = String::from("/");
362        buf.push_str(path);
363        let mut slash_pos = buf.rfind('/');
364        while let Some(pos) = slash_pos {
365            if pos == 0 {
366                break;
367            }
368            buf.truncate(pos);
369            if recursive.contains(&buf) {
370                return true;
371            }
372            slash_pos = buf.rfind('/');
373        }
374        false
375    }
376
377    /// Git `path_matches_pattern_list` for cone mode (`pathname` has no leading slash).
378    fn path_matches_pattern_list(&self, pathname: &str) -> ConeMatch {
379        if self.full_cone {
380            return ConeMatch::Matched;
381        }
382
383        let mut parent_pathname = String::with_capacity(pathname.len() + 2);
384        parent_pathname.push('/');
385        parent_pathname.push_str(pathname);
386
387        let slash_pos = if parent_pathname.ends_with('/') {
388            let sp = parent_pathname.len() - 1;
389            parent_pathname.push('-');
390            sp
391        } else {
392            parent_pathname.rfind('/').unwrap_or(0)
393        };
394
395        if self.recursive_slash.contains(&parent_pathname) {
396            return ConeMatch::MatchedRecursive;
397        }
398
399        if slash_pos == 0 {
400            return ConeMatch::Matched;
401        }
402
403        let parent_key = parent_pathname[..slash_pos].to_string();
404        if self.parent_slash.contains(&parent_key) {
405            return ConeMatch::Matched;
406        }
407
408        if Self::recursive_contains_parent(pathname, &self.recursive_slash) {
409            return ConeMatch::MatchedRecursive;
410        }
411
412        ConeMatch::NotMatched
413    }
414
415    /// Whether `path` (repository-relative, no leading slash) is inside the cone.
416    #[must_use]
417    pub fn path_included(&self, path: &str) -> bool {
418        if path.is_empty() {
419            return true;
420        }
421
422        let bytes = path.as_bytes();
423        let mut end = bytes.len();
424        let mut match_result = ConeMatch::Undecided;
425
426        while end > 0 && match_result == ConeMatch::Undecided {
427            let slice = path.get(..end).unwrap_or("");
428            match_result = self.path_matches_pattern_list(slice);
429
430            let mut slash = end.saturating_sub(1);
431            while slash > 0 && bytes[slash] != b'/' {
432                slash -= 1;
433            }
434            end = if bytes.get(slash) == Some(&b'/') {
435                slash
436            } else {
437                0
438            };
439        }
440
441        matches!(
442            match_result,
443            ConeMatch::Matched | ConeMatch::MatchedRecursive
444        )
445    }
446}
447
448/// Load sparse-checkout file; returns `(cone_parse_ok, cone, non_cone)`.
449#[must_use]
450pub fn load_sparse_checkout(
451    git_dir: &std::path::Path,
452    cone_config: bool,
453) -> (bool, Option<ConePatterns>, NonConePatterns) {
454    let mut w = Vec::new();
455    load_sparse_checkout_with_warnings(git_dir, cone_config, &mut w)
456}
457
458/// Like [`load_sparse_checkout`] but appends cone-parse warnings (for stderr).
459pub fn load_sparse_checkout_with_warnings(
460    git_dir: &std::path::Path,
461    cone_config: bool,
462    warnings: &mut Vec<String>,
463) -> (bool, Option<ConePatterns>, NonConePatterns) {
464    let path = git_dir.join("info").join("sparse-checkout");
465    let Ok(content) = std::fs::read_to_string(&path) else {
466        return (false, None, NonConePatterns { lines: Vec::new() });
467    };
468    let non_cone = NonConePatterns::parse(&content);
469    if !cone_config {
470        return (false, None, non_cone);
471    }
472    match ConePatterns::try_parse_with_warnings(&content, warnings) {
473        Some(cone) => (true, Some(cone), non_cone),
474        None => (false, None, non_cone),
475    }
476}
477
478/// If `path` is included in the sparse checkout.
479#[must_use]
480pub fn path_in_sparse_checkout(
481    path: &str,
482    cone_config: bool,
483    cone: Option<&ConePatterns>,
484    non_cone: &NonConePatterns,
485    work_tree: Option<&std::path::Path>,
486) -> bool {
487    if cone_config {
488        if let Some(c) = cone {
489            return c.path_included(path);
490        }
491    }
492    crate::ignore::path_in_sparse_checkout(path, non_cone.lines(), work_tree)
493}
494
495/// Apply sparse-checkout rules to `index`: stage-0 entries get `skip-worktree` when excluded.
496///
497/// Matches Git's sparse-checkout application used after building a new index from a tree
498/// (`read-tree`, branch checkout, fast-forward merge). When `core.sparseCheckout` is false or
499/// the sparse-checkout file is missing, this is a no-op.
500///
501/// # Parameters
502///
503/// - `git_dir` — repository git directory (reads `config` and `info/sparse-checkout`).
504/// - `work_tree` — when set, on-disk directories can match directory-only non-cone patterns.
505/// - `index` — index to update in place; bumped to version 3 when any entry is marked skip-worktree.
506/// - `skip_sparse_checkout` — when true (e.g. `read-tree --no-sparse-checkout`), do not set
507///   `skip-worktree` bits even if sparse checkout is enabled.
508///
509/// Returns cone-parse warnings when `core.sparseCheckoutCone` is set but the file is not a
510/// valid cone definition (matching then falls back to non-cone rules).
511pub fn apply_sparse_checkout_skip_worktree(
512    git_dir: &std::path::Path,
513    work_tree: Option<&std::path::Path>,
514    index: &mut crate::index::Index,
515    skip_sparse_checkout: bool,
516) -> Vec<String> {
517    if skip_sparse_checkout {
518        return Vec::new();
519    }
520    let config = crate::config::ConfigSet::load(
521        &crate::environment::Environment::empty(),
522        Some(git_dir),
523        true,
524    )
525    .unwrap_or_else(|_| crate::config::ConfigSet::new());
526    let mut warnings = Vec::new();
527    let Ok(Some(sparse)) =
528        SparseCheckout::from_config_with_warnings(git_dir, &config, &mut warnings)
529    else {
530        return warnings;
531    };
532    sparse.mark_index_entries(index, work_tree);
533    warnings
534}
535
536/// The active sparse-checkout definition of a repository, compiled once for path checks.
537///
538/// Checkout-updating operations (`reset --hard`, `switch`, `merge`, `pull`, `restore`, …) load
539/// this once per operation: paths it excludes are recorded in the index with the
540/// `skip-worktree` bit instead of being written to the work tree, like Git's sparse checkout.
541///
542/// [`SparseCheckout::load`] returns `None` when `core.sparseCheckout` is off or
543/// `info/sparse-checkout` does not exist, so non-sparse repositories pay only a config lookup.
544#[derive(Debug, Clone)]
545pub struct SparseCheckout {
546    rules: SparseRules,
547}
548
549#[derive(Debug, Clone)]
550enum SparseRules {
551    Cone(ConePatterns),
552    NonCone(crate::ignore::ParsedSparsePatterns),
553    /// A sparse-checkout file without any pattern excludes every path.
554    ExcludeAll,
555}
556
557impl SparseCheckout {
558    /// Load the sparse-checkout definition of `repo` from its config and `info/sparse-checkout`.
559    ///
560    /// Reads `core.sparseCheckout` and `core.sparseCheckoutCone` from the repository's cached
561    /// configuration; the pattern file is only read when sparse checkout is enabled.
562    ///
563    /// # Returns
564    ///
565    /// `Ok(None)` when sparse checkout is disabled or the pattern file is missing.
566    ///
567    /// # Errors
568    ///
569    /// [`crate::error::Error::Config`] when either key holds an invalid boolean, and
570    /// [`crate::error::Error::Io`] when the pattern file exists but cannot be read.
571    pub fn load(repo: &crate::repo::Repository) -> crate::error::Result<Option<Self>> {
572        let config = repo.config()?;
573        Self::from_config(repo.git_dir(), &config)
574    }
575
576    /// Like [`SparseCheckout::load`], with an explicit git directory and configuration.
577    ///
578    /// # Parameters
579    ///
580    /// - `git_dir` — directory holding `info/sparse-checkout` (the per-worktree git dir).
581    /// - `config` — configuration providing `core.sparseCheckout` / `core.sparseCheckoutCone`.
582    ///
583    /// # Errors
584    ///
585    /// Same as [`SparseCheckout::load`].
586    pub fn from_config(
587        git_dir: &std::path::Path,
588        config: &crate::config::ConfigSet,
589    ) -> crate::error::Result<Option<Self>> {
590        Self::from_config_with_warnings(git_dir, config, &mut Vec::new())
591    }
592
593    fn from_config_with_warnings(
594        git_dir: &std::path::Path,
595        config: &crate::config::ConfigSet,
596        warnings: &mut Vec<String>,
597    ) -> crate::error::Result<Option<Self>> {
598        let get_bool = |key: &str| -> crate::error::Result<bool> {
599            config
600                .get_bool(key)
601                .transpose()
602                .map(|v| v.unwrap_or(false))
603                .map_err(|e| crate::error::Error::Config(e.into()))
604        };
605        if !get_bool("core.sparsecheckout")? {
606            return Ok(None);
607        }
608        // Git's default for core.sparseCheckoutCone is false: an unset key means the file is
609        // matched with non-cone rules.
610        let cone = get_bool("core.sparsecheckoutcone")?;
611        let path = git_dir.join("info").join("sparse-checkout");
612        let content = match std::fs::read_to_string(&path) {
613            Ok(content) => content,
614            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
615            Err(e) => return Err(crate::error::Error::Io(e)),
616        };
617        Ok(Some(Self::parse_with_warnings(&content, cone, warnings)))
618    }
619
620    /// Compile sparse-checkout file `content`.
621    ///
622    /// # Parameters
623    ///
624    /// - `content` — text of `info/sparse-checkout`.
625    /// - `cone` — the value of `core.sparseCheckoutCone`; a file that is not a valid cone
626    ///   definition falls back to non-cone matching.
627    #[must_use]
628    pub fn parse(content: &str, cone: bool) -> Self {
629        Self::parse_with_warnings(content, cone, &mut Vec::new())
630    }
631
632    fn parse_with_warnings(content: &str, cone: bool, warnings: &mut Vec<String>) -> Self {
633        let lines = parse_sparse_checkout_file(content);
634        if lines.is_empty() {
635            return Self {
636                rules: SparseRules::ExcludeAll,
637            };
638        }
639        let cone_patterns = if cone {
640            ConePatterns::try_parse_with_warnings(content, warnings)
641        } else {
642            None
643        };
644        let rules = match cone_patterns {
645            Some(cone) => SparseRules::Cone(cone),
646            None => SparseRules::NonCone(crate::ignore::ParsedSparsePatterns::parse(&lines)),
647        };
648        Self { rules }
649    }
650
651    /// Whether the repository-relative file `path` (`/`-separated) belongs in the work tree.
652    #[must_use]
653    pub fn includes(&self, path: &str) -> bool {
654        self.includes_at(path, None)
655    }
656
657    fn includes_at(&self, path: &str, work_tree: Option<&std::path::Path>) -> bool {
658        match &self.rules {
659            SparseRules::Cone(cone) => cone.path_included(path),
660            SparseRules::NonCone(patterns) => patterns.includes(path, work_tree),
661            SparseRules::ExcludeAll => false,
662        }
663    }
664
665    /// Like [`SparseCheckout::includes`], for a raw index path.
666    #[must_use]
667    pub fn includes_index_path(&self, path: &[u8]) -> bool {
668        self.includes(&String::from_utf8_lossy(path))
669    }
670
671    /// Only cone definitions can keep an excluded directory as an opaque index row.
672    pub(crate) fn excludes_directory(&self, path: &[u8]) -> bool {
673        matches!(&self.rules, SparseRules::Cone(cone)
674            if !cone.path_included(&String::from_utf8_lossy(path)))
675    }
676
677    /// Whether a checkout that gives `path` new content keeps it out of the work tree.
678    ///
679    /// True when `path` is outside the definition and `index` (the index before the checkout)
680    /// either marks it skip-worktree or has no entry for it at all. A path whose file is
681    /// already materialized, or that is conflicted, is checked out normally.
682    #[must_use]
683    pub(crate) fn checkout_skips(&self, index: &crate::index::Index, path: &str) -> bool {
684        if self.includes(path) {
685            return false;
686        }
687        let bytes = crate::repo_relpath::storage_bytes(path);
688        match index.get(&bytes, 0) {
689            Some(entry) => entry.skip_worktree(),
690            None => (1..=3).all(|stage| index.get(&bytes, stage).is_none()),
691        }
692    }
693
694    /// Set `skip-worktree` on every stage-0 entry of `index` that is outside the definition
695    /// and clear it on the others; bumps the index to version 3 when any bit is set.
696    ///
697    /// # Parameters
698    ///
699    /// - `index` — index to update in place.
700    /// - `work_tree` — when set, on-disk directories can match directory-only non-cone patterns.
701    pub fn mark_index_entries(
702        &self,
703        index: &mut crate::index::Index,
704        work_tree: Option<&std::path::Path>,
705    ) {
706        let mut any_skip = false;
707        for entry in index.entries.iter_mut().filter(|e| e.stage() == 0) {
708            let included = self.includes_at(&String::from_utf8_lossy(&entry.path), work_tree);
709            entry.set_skip_worktree(!included);
710            any_skip |= !included;
711        }
712        if any_skip && index.version < 3 {
713            index.version = 3;
714        }
715    }
716
717    /// Carry sparse state into `index`, an index rebuilt from a tree without touching the
718    /// work tree (mixed reset, merge abort, stash): a stage-0 entry gets `skip-worktree` when
719    /// `previous` marks the same path skip-worktree, or when `previous` has no entry for the
720    /// path and the path is outside the definition.
721    ///
722    /// # Parameters
723    ///
724    /// - `index` — the rebuilt index, updated in place.
725    /// - `previous` — the index the rebuild replaces.
726    pub fn carry_skip_worktree(
727        &self,
728        index: &mut crate::index::Index,
729        previous: &crate::index::Index,
730    ) {
731        for entry in index.entries.iter_mut().filter(|e| e.stage() == 0) {
732            let skip = match previous.get(&entry.path, 0) {
733                Some(prev) => prev.skip_worktree(),
734                None => {
735                    (1..=3).all(|stage| previous.get(&entry.path, stage).is_none())
736                        && !self.includes_index_path(&entry.path)
737                }
738            };
739            entry.set_skip_worktree(skip);
740        }
741    }
742}
743
744/// Longest common prefix of `path1` and `path2` that ends at a `/` (Git `max_common_dir_prefix`).
745fn max_common_dir_prefix(path1: &str, path2: &str) -> usize {
746    let b1 = path1.as_bytes();
747    let b2 = path2.as_bytes();
748    let mut common_prefix = 0usize;
749    let mut i = 0usize;
750    while i < b1.len() && i < b2.len() {
751        if b1[i] != b2[i] {
752            break;
753        }
754        if b1[i] == b'/' {
755            common_prefix = i + 1;
756        }
757        i += 1;
758    }
759    common_prefix
760}
761
762struct PathFoundData {
763    /// Cached path prefix that does not exist, always ending with `/` when non-empty.
764    dir: String,
765}
766
767/// Whether `path` names an existing file or symlink (Git `path_found` in `sparse-index.c`).
768fn path_found(path: &str, data: &mut PathFoundData) -> bool {
769    let pb = path.as_bytes();
770    let db = data.dir.as_bytes();
771    if !db.is_empty() && pb.len() >= db.len() && pb[..db.len()] == *db {
772        return false;
773    }
774
775    if std::fs::symlink_metadata(std::path::Path::new(path)).is_ok() {
776        return true;
777    }
778
779    let common_prefix = max_common_dir_prefix(path, &data.dir);
780    data.dir.truncate(common_prefix);
781
782    loop {
783        let rest = &path[data.dir.len()..];
784        if let Some(rel_slash) = rest.find('/') {
785            data.dir.push_str(&rest[..=rel_slash]);
786            if std::fs::symlink_metadata(std::path::Path::new(&data.dir)).is_err() {
787                return false;
788            }
789        } else {
790            data.dir.push_str(rest);
791            data.dir.push('/');
792            break;
793        }
794    }
795    false
796}
797
798/// Clear `skip-worktree` on index entries whose paths exist in the work tree when sparse checkout
799/// is enabled, unless `sparse.expectFilesOutsideOfPatterns` is true.
800///
801/// Matches Git's `clear_skip_worktree_from_present_files` (`sparse-index.c`) for a full
802/// (non-sparse-index) in-memory index.
803pub fn clear_skip_worktree_from_present_files(
804    git_dir: &std::path::Path,
805    work_tree: &std::path::Path,
806    index: &mut crate::index::Index,
807    config: Option<&crate::config::ConfigSet>,
808) {
809    let config = config.cloned().unwrap_or_else(|| {
810        crate::config::ConfigSet::load(
811            &crate::environment::Environment::empty(),
812            Some(git_dir),
813            true,
814        )
815        .unwrap_or_default()
816    });
817    let sparse_enabled = config
818        .get_bool("core.sparsecheckout")
819        .and_then(|r| r.ok())
820        .unwrap_or(false);
821    if !sparse_enabled {
822        return;
823    }
824    if config
825        .get_bool("sparse.expectfilesoutsideofpatterns")
826        .and_then(|r| r.ok())
827        .unwrap_or(false)
828    {
829        return;
830    }
831
832    let mut found = PathFoundData { dir: String::new() };
833    for entry in &mut index.entries {
834        if entry.stage() != 0 || !entry.skip_worktree() {
835            continue;
836        }
837        // With assume-unchanged (CE_VALID), Git keeps skip-worktree for `git grep` semantics
838        // (t7817: present file + both bits still excluded from work-tree index grep).
839        if entry.assume_unchanged() {
840            continue;
841        }
842        let rel = String::from_utf8_lossy(&entry.path);
843        let abs = work_tree.join(rel.as_ref());
844        let abs_str = abs.to_string_lossy().into_owned();
845        if path_found(&abs_str, &mut found) {
846            entry.set_skip_worktree(false);
847        }
848    }
849}
850
851/// Mutable cone sparse state (Git `pattern_list` hashmaps) for building `sparse-checkout` files.
852#[derive(Debug, Clone, Default)]
853pub struct ConeWorkspace {
854    /// `recursive_slash` field.
855    pub recursive_slash: BTreeSet<String>,
856    /// `parent_slash` field.
857    pub parent_slash: BTreeSet<String>,
858}
859
860impl ConeWorkspace {
861    /// Build from parsed cone file content.
862    #[must_use]
863    pub fn from_cone_patterns(cp: &ConePatterns) -> Self {
864        Self {
865            recursive_slash: cp.recursive_slash.clone(),
866            parent_slash: cp.parent_slash.clone(),
867        }
868    }
869
870    /// Rebuild from a set of repository-relative directory paths (after pruning descendants).
871    #[must_use]
872    pub fn from_directory_list(dirs: &[String]) -> Self {
873        let mut pruned: Vec<String> = dirs
874            .iter()
875            .map(|s| s.trim_start_matches('/').trim_end_matches('/').to_string())
876            .filter(|s| !s.is_empty())
877            .collect();
878        pruned.sort();
879        let mut kept: Vec<String> = Vec::new();
880        for d in pruned {
881            if kept
882                .iter()
883                .any(|p| d.starts_with(p) && d.as_bytes().get(p.len()) == Some(&b'/'))
884            {
885                continue;
886            }
887            kept.retain(|k| !(k.starts_with(&d) && k.as_bytes().get(d.len()) == Some(&b'/')));
888            kept.push(d);
889        }
890        let mut ws = ConeWorkspace::default();
891        for d in kept {
892            ws.insert_directory(&d);
893        }
894        ws
895    }
896
897    /// Insert a repository-relative directory path (no leading slash).
898    pub fn insert_directory(&mut self, rel: &str) {
899        let rel = rel.trim_start_matches('/');
900        let rel = rel.trim_end_matches('/');
901        if rel.is_empty() {
902            return;
903        }
904        let key = format!("/{rel}");
905        if self.parent_slash.contains(&key) {
906            return;
907        }
908        self.recursive_slash.insert(key.clone());
909        let parts: Vec<&str> = rel.split('/').collect();
910        for i in 1..parts.len() {
911            let prefix = parts[..i].join("/");
912            self.parent_slash.insert(format!("/{prefix}"));
913        }
914    }
915
916    fn recursive_contains_parent(path_slash: &str, recursive: &BTreeSet<String>) -> bool {
917        let mut buf = String::from(path_slash);
918        let mut slash_pos = buf.rfind('/');
919        while let Some(pos) = slash_pos {
920            if pos == 0 {
921                break;
922            }
923            buf.truncate(pos);
924            if recursive.contains(&buf) {
925                return true;
926            }
927            slash_pos = buf.rfind('/');
928        }
929        false
930    }
931
932    /// Serialize to `.git/info/sparse-checkout` cone format (includes `/*` and `!/*/` header).
933    #[must_use]
934    pub fn to_sparse_checkout_file(&self) -> String {
935        let mut parent_only: Vec<&String> = self
936            .parent_slash
937            .iter()
938            .filter(|p| {
939                !self.recursive_slash.contains(*p)
940                    && !Self::recursive_contains_parent(p, &self.recursive_slash)
941            })
942            .collect();
943        parent_only.sort();
944
945        let mut out = String::new();
946        out.push_str("/*\n!/*/\n");
947
948        for p in parent_only {
949            let esc = escape_cone_path_component(p);
950            out.push_str(&esc);
951            out.push_str("/\n!");
952            out.push_str(&esc);
953            out.push_str("/*/\n");
954        }
955
956        let mut rec_only: Vec<&String> = self
957            .recursive_slash
958            .iter()
959            .filter(|p| !Self::recursive_contains_parent(p, &self.recursive_slash))
960            .collect();
961        rec_only.sort();
962
963        for p in rec_only {
964            let esc = escape_cone_path_component(p);
965            out.push_str(&esc);
966            out.push_str("/\n");
967        }
968        out
969    }
970
971    /// Directory names for `git sparse-checkout list` in cone mode (no leading slash).
972    #[must_use]
973    pub fn list_cone_directories(&self) -> Vec<String> {
974        let mut v: Vec<String> = self
975            .recursive_slash
976            .iter()
977            .map(|s| s.trim_start_matches('/').to_string())
978            .collect();
979        v.sort();
980        v
981    }
982}
983
984fn escape_cone_path_component(path_with_leading_slash: &str) -> String {
985    let mut out = String::new();
986    for ch in path_with_leading_slash.chars() {
987        if matches!(ch, '*' | '?' | '[' | '\\') {
988            out.push('\\');
989        }
990        out.push(ch);
991    }
992    out
993}
994
995/// Read non-empty, non-comment lines from `.git/info/sparse-checkout`.
996pub fn parse_sparse_checkout_file(content: &str) -> Vec<String> {
997    content
998        .lines()
999        .map(|l| l.trim())
1000        .filter(|l| !l.is_empty() && !l.starts_with('#'))
1001        .map(String::from)
1002        .collect()
1003}
1004
1005/// Returns true when the sparse-checkout file uses Git's expanded cone format
1006/// (starts with `/*` then `!/*/`).
1007pub fn sparse_checkout_lines_look_like_expanded_cone(lines: &[String]) -> bool {
1008    lines.len() >= 2 && lines[0] == "/*" && lines[1] == "!/*/"
1009}
1010
1011/// Parent and recursive directory prefixes (no leading slash, no trailing slash) from an
1012/// expanded cone sparse-checkout file, matching Git `write_cone_to_file` layout.
1013fn parse_expanded_cone_parent_recursive(lines: &[String]) -> Option<(Vec<String>, Vec<String>)> {
1014    if !sparse_checkout_lines_look_like_expanded_cone(lines) {
1015        return None;
1016    }
1017    let mut parents = Vec::new();
1018    let mut recursive = Vec::new();
1019    let mut i = 2usize;
1020    while i + 1 < lines.len() {
1021        let a = &lines[i];
1022        let b = &lines[i + 1];
1023        if !a.starts_with('/') || !a.ends_with('/') || !b.starts_with('!') {
1024            break;
1025        }
1026        let inner_a = a.trim_start_matches('/').trim_end_matches('/');
1027        let expected_neg = format!("!/{inner_a}/*/");
1028        if b != &expected_neg {
1029            break;
1030        }
1031        // On-disk patterns escape glob/backslash chars; match against the literal directory name.
1032        parents.push(unescape_cone_pattern_path(inner_a));
1033        i += 2;
1034    }
1035    while i < lines.len() {
1036        let line = &lines[i];
1037        if line.starts_with('!') {
1038            return None;
1039        }
1040        if !line.starts_with('/') || !line.ends_with('/') {
1041            return None;
1042        }
1043        let body = line.trim_start_matches('/').trim_end_matches('/');
1044        if body.is_empty() {
1045            return None;
1046        }
1047        recursive.push(unescape_cone_pattern_path(body));
1048        i += 1;
1049    }
1050    Some((parents, recursive))
1051}
1052
1053fn path_in_expanded_cone(path: &str, lines: &[String]) -> bool {
1054    let Some((parents, recursive)) = parse_expanded_cone_parent_recursive(lines) else {
1055        return false;
1056    };
1057    let raw = path.trim_start_matches('/');
1058    let is_directory_path = raw.ends_with('/');
1059    let path = raw.trim_end_matches('/');
1060
1061    if !path.contains('/') {
1062        // Top-level: files are always in-cone (`/*`). Directories are in-cone only when
1063        // they lead into an expanded parent/recursive rule (Git uses dtype when matching).
1064        // Callers pass directories with a trailing slash (e.g. `a/`); files have none.
1065        if !is_directory_path {
1066            return true;
1067        }
1068        if parents.is_empty() && recursive.is_empty() {
1069            return false;
1070        }
1071        return parents.iter().any(|p| p == path)
1072            || recursive
1073                .iter()
1074                .any(|r| r == path || r.starts_with(&format!("{path}/")));
1075    }
1076
1077    for r in &recursive {
1078        if path == *r || path.starts_with(&format!("{r}/")) {
1079            return true;
1080        }
1081    }
1082
1083    for p in &parents {
1084        let p_slash = format!("{}/", p);
1085        if path == *p {
1086            return true;
1087        }
1088        if !path.starts_with(&p_slash) {
1089            continue;
1090        }
1091        let rest = &path[p_slash.len()..];
1092        let Some(slash_pos) = rest.find('/') else {
1093            // Immediate child `p/name`. Git's cone for a parent emits `/p/` plus `!/p/*/`:
1094            // a FILE directly in `p/` matches `/p/` (the `!/p/*/` exclusion only matches
1095            // sub-DIRECTORIES, never files), so `sub/d` is in-cone. A directory directly in
1096            // `p/` is excluded by `!/p/*/` unless a recursive rule re-includes it
1097            // (e.g. `sub/dir` under parent `sub`).
1098            if !is_directory_path {
1099                return true;
1100            }
1101            let combined = format!("{}/{}", p, rest);
1102            return recursive
1103                .iter()
1104                .any(|r| r == &combined || r.starts_with(&format!("{combined}/")));
1105        };
1106        let first = &rest[..slash_pos];
1107        let combined = format!("{}/{}", p, first);
1108        for r in &recursive {
1109            let under_r = path == *r || path.starts_with(&format!("{r}/"));
1110            let r_covers = r == &combined || r.starts_with(&format!("{combined}/"));
1111            if r_covers && under_r {
1112                return true;
1113            }
1114        }
1115    }
1116
1117    false
1118}
1119
1120/// Cone mode from config combined with on-disk pattern shape.
1121///
1122/// Git parses the sparse-checkout file in cone mode only when it matches the
1123/// expanded template (`/*`, `!/*/`, …). Raw lines like `a` are matched as
1124/// non-cone patterns even if `core.sparseCheckoutCone` is true.
1125#[must_use]
1126pub fn effective_cone_mode_for_sparse_file(cone_config: bool, lines: &[String]) -> bool {
1127    cone_config && sparse_checkout_lines_look_like_expanded_cone(lines)
1128}
1129
1130/// Build the on-disk sparse-checkout contents for cone mode, matching
1131/// `write_cone_to_file` in Git's `builtin/sparse-checkout.c`.
1132///
1133/// `dirs` are worktree-relative directory paths as the user typed them (no
1134/// leading slash, `/` separators). Empty entries are ignored.
1135pub fn build_expanded_cone_sparse_checkout_lines(dirs: &[String]) -> Vec<String> {
1136    let mut recursive: BTreeSet<String> = BTreeSet::new();
1137    for d in dirs {
1138        let t = d.trim().trim_start_matches('/').trim_end_matches('/');
1139        if t.is_empty() {
1140            continue;
1141        }
1142        recursive.insert(format!("/{t}"));
1143    }
1144
1145    let mut parents: BTreeSet<String> = BTreeSet::new();
1146    for r in &recursive {
1147        let mut cur = r.clone();
1148        while let Some(slash) = cur.rfind('/') {
1149            if slash == 0 {
1150                break;
1151            }
1152            cur.truncate(slash);
1153            parents.insert(cur.clone());
1154        }
1155    }
1156
1157    let mut out = vec!["/*".to_owned(), "!/*/".to_owned()];
1158
1159    for p in parents.iter() {
1160        if recursive.contains(p) {
1161            continue;
1162        }
1163        if recursive_set_has_strict_ancestor(&recursive, p) {
1164            continue;
1165        }
1166        let esc = escape_cone_pattern_path(p);
1167        out.push(format!("{esc}/"));
1168        out.push(format!("!{esc}/*/"));
1169    }
1170
1171    for r in recursive.iter() {
1172        if recursive_set_has_strict_ancestor(&recursive, r) {
1173            continue;
1174        }
1175        let esc = escape_cone_pattern_path(r);
1176        out.push(format!("{esc}/"));
1177    }
1178
1179    out
1180}
1181
1182fn escape_cone_pattern_path(path_with_leading_slash: &str) -> String {
1183    // Git's `escaped_pattern` escapes backslashes, `[`, `*`, `?`, `#`; keep
1184    // tests (and normal paths) working with a minimal escape pass.
1185    let mut out = String::with_capacity(path_with_leading_slash.len() + 8);
1186    for ch in path_with_leading_slash.chars() {
1187        match ch {
1188            '\\' | '[' | '*' | '?' | '#' => {
1189                out.push('\\');
1190                out.push(ch);
1191            }
1192            _ => out.push(ch),
1193        }
1194    }
1195    out
1196}
1197
1198/// Inverse of [`escape_cone_pattern_path`]: drop the escaping backslash before a glob/backslash
1199/// character so a cone pattern body becomes the plain directory name.
1200fn unescape_cone_pattern_path(escaped: &str) -> String {
1201    let mut out = String::with_capacity(escaped.len());
1202    let mut chars = escaped.chars().peekable();
1203    while let Some(ch) = chars.next() {
1204        if ch == '\\' {
1205            if let Some(&next) = chars.peek() {
1206                if matches!(next, '\\' | '[' | '*' | '?' | '#') {
1207                    out.push(next);
1208                    chars.next();
1209                    continue;
1210                }
1211            }
1212        }
1213        out.push(ch);
1214    }
1215    out
1216}
1217
1218fn recursive_set_has_strict_ancestor(recursive: &BTreeSet<String>, path: &str) -> bool {
1219    let mut cur = path.to_string();
1220    while let Some(slash) = cur.rfind('/') {
1221        if slash == 0 {
1222            break;
1223        }
1224        cur.truncate(slash);
1225        if recursive.contains(&cur) {
1226            return true;
1227        }
1228    }
1229    false
1230}
1231
1232/// Parse recursive directory paths from an expanded cone sparse-checkout file
1233/// (for merging on `sparse-checkout add`).
1234pub fn parse_expanded_cone_recursive_dirs(lines: &[String]) -> Vec<String> {
1235    if !sparse_checkout_lines_look_like_expanded_cone(lines) {
1236        return Vec::new();
1237    }
1238    let mut i = 2usize;
1239    let mut out = Vec::new();
1240    while i < lines.len() {
1241        let line = &lines[i];
1242        if line.starts_with('!') {
1243            i += 1;
1244            continue;
1245        }
1246        if !line.ends_with('/') || !line.starts_with('/') {
1247            i += 1;
1248            continue;
1249        }
1250        let trimmed = line.trim_end_matches('/');
1251        let body = trimmed.trim_start_matches('/');
1252        let esc = escape_cone_pattern_path(trimmed);
1253        let expected_neg = format!("!{esc}/*/");
1254        if i + 1 < lines.len() && lines[i + 1] == expected_neg {
1255            i += 2;
1256            continue;
1257        }
1258        // On-disk cone patterns escape glob/backslash characters (`escaped_pattern`); the
1259        // directory name itself (Git's recursive hashmap key) is the unescaped form.
1260        out.push(unescape_cone_pattern_path(body));
1261        i += 1;
1262    }
1263    out
1264}
1265
1266/// Directory paths to merge with new inputs for `git sparse-checkout add` when cone mode is on.
1267///
1268/// Git loads the existing file with `core.sparseCheckoutCone` set, then checks
1269/// `existing.use_cone_patterns` after parsing. When the file has the expanded-cone header (`/*`,
1270/// `!/*/`) but non-cone body lines (e.g. a bare `dir` after `init --no-cone`), the file is not cone
1271/// mode and the merge uses literal pattern lines as directory names — not
1272/// [`parse_expanded_cone_recursive_dirs`], which would skip those lines and wrongly treat the file as
1273/// an empty cone list.
1274#[must_use]
1275pub fn cone_directory_inputs_for_add(content: &str) -> Vec<String> {
1276    let lines: Vec<String> = parse_sparse_checkout_file(content);
1277    if sparse_checkout_lines_look_like_expanded_cone(&lines) {
1278        let recursive = parse_expanded_cone_recursive_dirs(&lines);
1279        if !recursive.is_empty() {
1280            return recursive;
1281        }
1282        if lines.len() == 2 {
1283            return Vec::new();
1284        }
1285        // Header matches expanded cone but body lines are not in expanded form (e.g. bare `dir`
1286        // after `init --no-cone`). Merge uses those literals — do not strip with
1287        // `trim_start_matches('/')` on the whole file (would corrupt `/*`).
1288        return lines[2..]
1289            .iter()
1290            .map(|s| {
1291                s.trim()
1292                    .trim_start_matches('/')
1293                    .trim_end_matches('/')
1294                    .to_string()
1295            })
1296            .filter(|s| !s.is_empty())
1297            .collect();
1298    }
1299    if let Some(cp) = ConePatterns::try_parse(content) {
1300        return ConeWorkspace::from_cone_patterns(&cp).list_cone_directories();
1301    }
1302    lines
1303        .iter()
1304        .map(|s| {
1305            s.trim()
1306                .trim_start_matches('/')
1307                .trim_end_matches('/')
1308                .to_string()
1309        })
1310        .filter(|s| !s.is_empty())
1311        .collect()
1312}
1313
1314/// Returns true when `path` is included in the sparse-checkout definition.
1315///
1316/// Implements parent-directory fallback like Git's `path_in_sparse_checkout`:
1317/// if the full path does not match, successively shorter prefixes (directory
1318/// parents) are tried until one matches or the path is exhausted.
1319///
1320/// `path` must use `/` separators and be relative to the repository root.
1321pub fn path_in_sparse_checkout_patterns(path: &str, patterns: &[String], cone_mode: bool) -> bool {
1322    if path.is_empty() || patterns.is_empty() {
1323        return true;
1324    }
1325
1326    // Git's expanded cone file uses parent + recursive directory rules, not plain gitignore
1327    // wildmatch on each line (see `write_cone_to_file` / `path_matches_pattern_list`).
1328    if sparse_checkout_lines_look_like_expanded_cone(patterns) {
1329        return path_in_expanded_cone(path, patterns);
1330    }
1331
1332    // Prefix-directory rules apply to **raw** cone patterns on disk (e.g. `sub`).
1333    let use_cone_prefix = cone_mode;
1334
1335    let mut end = path.len();
1336    while end > 0 {
1337        if path_matches_sparse_patterns(&path[..end], patterns, use_cone_prefix) {
1338            return true;
1339        }
1340        let Some(slash) = path[..end].rfind('/') else {
1341            break;
1342        };
1343        end = slash;
1344    }
1345    false
1346}
1347
1348/// Like [`path_in_sparse_checkout_patterns`], but only applies when `cone_enabled` is true.
1349///
1350/// When sparse-checkout is not in cone mode, Git treats every path as "in" for
1351/// this check (backward compatibility for file destinations).
1352pub fn path_in_cone_mode_sparse_checkout(
1353    path: &str,
1354    patterns: &[String],
1355    cone_enabled: bool,
1356) -> bool {
1357    if !cone_enabled || patterns.is_empty() {
1358        return true;
1359    }
1360    path_in_sparse_checkout_patterns(path, patterns, true)
1361}
1362
1363/// Returns true when `path` is included, using the same rules as
1364/// `grit sparse-checkout` / `apply_sparse_patterns`.
1365pub fn path_matches_sparse_patterns(path: &str, patterns: &[String], cone_mode: bool) -> bool {
1366    let expanded_cone = sparse_checkout_lines_look_like_expanded_cone(patterns);
1367    if expanded_cone {
1368        return path_in_expanded_cone(path, patterns);
1369    }
1370    // Raw cone mode (`sparse-checkout set --cone sub` writing only `sub`): directory-prefix rules.
1371    // Expanded on-disk cone (`/*`, `!/*/`, `/sub/`, …): use full pattern matching like Git.
1372    let raw_cone_prefix = cone_mode && !expanded_cone;
1373
1374    if raw_cone_prefix {
1375        if !path.contains('/') {
1376            return true;
1377        }
1378
1379        for pattern in patterns {
1380            let prefix = pattern.trim_end_matches('/');
1381            if path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/') {
1382                return true;
1383            }
1384            if path == prefix {
1385                return true;
1386            }
1387        }
1388        return false;
1389    }
1390
1391    let mut included = false;
1392    for raw_pattern in patterns {
1393        let pattern = raw_pattern.trim();
1394        if pattern.is_empty() || pattern.starts_with('#') {
1395            continue;
1396        }
1397
1398        let (negated, core_pattern) = if let Some(rest) = pattern.strip_prefix('!') {
1399            (true, rest)
1400        } else {
1401            (false, pattern)
1402        };
1403        if core_pattern.is_empty() || core_pattern == "/" {
1404            continue;
1405        }
1406
1407        let matches = if let Some(prefix_with_slash) = core_pattern.strip_suffix('/') {
1408            // Directory-only patterns: `/a/` or `a/`.
1409            let inner = prefix_with_slash.trim_start_matches('/');
1410            if inner.is_empty() {
1411                false
1412            } else if inner == "*" {
1413                // `/*/` and `!/*/` in expanded-cone files: match only nested paths (contain `/`),
1414                // not every top-level name (plain `wildmatch("*", …)` would match `sub2`, etc.).
1415                let trimmed = path.trim_end_matches('/');
1416                trimmed.contains('/')
1417            } else if inner.contains('*') || inner.contains('?') || inner.contains('[') {
1418                // e.g. `!/sub/*/` in expanded cone mode
1419                let pat = format!("{prefix_with_slash}/");
1420                let text = format!("/{path}/");
1421                wildmatch(pat.as_bytes(), text.as_bytes(), WM_PATHNAME)
1422            } else {
1423                path == inner || path.starts_with(&format!("{inner}/"))
1424            }
1425        } else if core_pattern.starts_with('/') {
1426            // Leading `/` anchors to repo root (same as gitignore / sparse-checkout).
1427            let text = format!("/{}", path.trim_start_matches('/'));
1428            wildmatch(core_pattern.as_bytes(), text.as_bytes(), WM_PATHNAME)
1429        } else {
1430            wildmatch(core_pattern.as_bytes(), path.as_bytes(), WM_PATHNAME)
1431        };
1432
1433        if matches {
1434            included = !negated;
1435        }
1436    }
1437
1438    included
1439}
1440
1441#[cfg(test)]
1442mod path_in_expanded_cone_tests {
1443    use super::path_in_sparse_checkout_patterns;
1444
1445    #[test]
1446    fn root_only_cone_includes_files_not_top_level_dirs() {
1447        let lines = vec!["/*".to_string(), "!/*/".to_string()];
1448        assert!(path_in_sparse_checkout_patterns("file.1.txt", &lines, true));
1449        assert!(!path_in_sparse_checkout_patterns("a/", &lines, true));
1450        assert!(!path_in_sparse_checkout_patterns("d/", &lines, true));
1451    }
1452
1453    #[test]
1454    fn expanded_cone_with_d_includes_d_tree_not_sibling_a() {
1455        let lines = vec!["/*".to_string(), "!/*/".to_string(), "/d/".to_string()];
1456        assert!(path_in_sparse_checkout_patterns("file.1.txt", &lines, true));
1457        assert!(path_in_sparse_checkout_patterns("d/", &lines, true));
1458        assert!(!path_in_sparse_checkout_patterns("a/", &lines, true));
1459        assert!(path_in_sparse_checkout_patterns(
1460            "d/e/file.1.txt",
1461            &lines,
1462            true
1463        ));
1464    }
1465}
1466
1467#[cfg(test)]
1468mod cone_directory_inputs_for_add_tests {
1469    use super::cone_directory_inputs_for_add;
1470
1471    #[test]
1472    fn expanded_header_with_non_cone_body_preserves_literal_dir() {
1473        let content = "/*\n!/*/\ndir\n";
1474        assert_eq!(
1475            cone_directory_inputs_for_add(content),
1476            vec!["dir".to_string()]
1477        );
1478    }
1479
1480    #[test]
1481    fn pure_expanded_cone_uses_recursive_dirs_only() {
1482        let content = "/*\n!/*/\n/sub/\n";
1483        assert_eq!(
1484            cone_directory_inputs_for_add(content),
1485            vec!["sub".to_string()]
1486        );
1487    }
1488}
1489
1490#[cfg(test)]
1491mod sparse_public_api_coverage {
1492    use super::*;
1493    use std::fs;
1494    use tempfile::TempDir;
1495
1496    fn write_sparse_repo(cone: bool, body: &str) -> TempDir {
1497        let tmp = TempDir::new().expect("tempdir");
1498        let git_dir = tmp.path().join(".git");
1499        fs::create_dir_all(git_dir.join("info")).expect("mkdir");
1500        fs::write(
1501            git_dir.join("config"),
1502            format!(
1503                "[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tsparseCheckout = true\n\tsparseCheckoutCone = {}\n",
1504                if cone { "true" } else { "false" }
1505            ),
1506        )
1507        .expect("config");
1508        fs::write(git_dir.join("info/sparse-checkout"), body).expect("sparse");
1509        tmp
1510    }
1511
1512    #[test]
1513    fn load_sparse_checkout_and_warnings() {
1514        let tmp = write_sparse_repo(true, "/*\n!/*/\n/docs/\n");
1515        let mut warnings = Vec::new();
1516        let (ok, cone, non_cone) = load_sparse_checkout_with_warnings(
1517            tmp.path().join(".git").as_path(),
1518            true,
1519            &mut warnings,
1520        );
1521        assert!(ok);
1522        assert!(cone.is_some());
1523        assert!(!non_cone.lines().is_empty());
1524        let (ok2, cone2, non2) = load_sparse_checkout(tmp.path().join(".git").as_path(), false);
1525        assert!(!ok2);
1526        assert!(cone2.is_none());
1527        assert!(!non2.lines().is_empty());
1528    }
1529
1530    #[test]
1531    fn expanded_cone_builders_and_parsers() {
1532        let lines = build_expanded_cone_sparse_checkout_lines(&["src".to_string()]);
1533        assert!(sparse_checkout_lines_look_like_expanded_cone(&lines));
1534        assert!(effective_cone_mode_for_sparse_file(true, &lines));
1535        let rec = parse_expanded_cone_recursive_dirs(&lines);
1536        assert!(!rec.is_empty());
1537        assert!(path_matches_sparse_patterns("src/x", &lines, true));
1538    }
1539
1540    #[test]
1541    fn path_in_sparse_checkout_wrapper() {
1542        let patterns = NonConePatterns::parse("/*\n!/*/\ntop/\n");
1543        assert!(path_in_sparse_checkout(
1544            "top/x", false, None, &patterns, None
1545        ));
1546    }
1547
1548    #[test]
1549    fn apply_sparse_respects_skip_flag_and_empty_file() {
1550        let tmp = write_sparse_repo(true, "\n\n");
1551        let git_dir = tmp.path().join(".git");
1552        let wt = tmp.path();
1553        fs::write(wt.join("file.txt"), "x").expect("write");
1554        let mut index = crate::index::Index::new();
1555        index.add_or_replace(crate::index::IndexEntry {
1556            ctime_sec: 0,
1557            ctime_nsec: 0,
1558            mtime_sec: 0,
1559            mtime_nsec: 0,
1560            dev: 0,
1561            ino: 0,
1562            mode: crate::index::MODE_REGULAR,
1563            uid: 0,
1564            gid: 0,
1565            size: 1,
1566            oid: crate::objects::ObjectId::zero(),
1567            flags: 8,
1568            flags_extended: None,
1569            path: b"file.txt".to_vec(),
1570            base_index_pos: 0,
1571        });
1572        assert!(
1573            apply_sparse_checkout_skip_worktree(&git_dir, Some(wt), &mut index, true).is_empty()
1574        );
1575        assert!(!index.entries()[0].skip_worktree());
1576        apply_sparse_checkout_skip_worktree(&git_dir, Some(wt), &mut index, false);
1577        assert!(index.entries()[0].skip_worktree());
1578    }
1579
1580    fn stage0_entry(path: &str) -> crate::index::IndexEntry {
1581        crate::index::IndexEntry {
1582            ctime_sec: 0,
1583            ctime_nsec: 0,
1584            mtime_sec: 0,
1585            mtime_nsec: 0,
1586            dev: 0,
1587            ino: 0,
1588            mode: crate::index::MODE_REGULAR,
1589            uid: 0,
1590            gid: 0,
1591            size: 0,
1592            oid: crate::objects::ObjectId::zero(),
1593            flags: path.len().min(0xFFF) as u16,
1594            flags_extended: None,
1595            path: path.as_bytes().to_vec(),
1596            base_index_pos: 0,
1597        }
1598    }
1599
1600    fn index_of(paths: &[&str]) -> crate::index::Index {
1601        let mut index = crate::index::Index::new();
1602        for path in paths {
1603            index.add_or_replace(stage0_entry(path));
1604        }
1605        index.sort();
1606        index
1607    }
1608
1609    fn skip_bits(index: &crate::index::Index) -> Vec<(String, bool)> {
1610        index
1611            .entries()
1612            .iter()
1613            .map(|e| {
1614                (
1615                    String::from_utf8_lossy(&e.path).into_owned(),
1616                    e.skip_worktree(),
1617                )
1618            })
1619            .collect()
1620    }
1621
1622    #[test]
1623    fn sparse_checkout_from_config_reads_keys_and_file() {
1624        let tmp = write_sparse_repo(true, "/*\n!/*/\n/a/\n");
1625        let git_dir = tmp.path().join(".git");
1626        let mut config = crate::config::ConfigSet::new();
1627        assert!(SparseCheckout::from_config(&git_dir, &config)
1628            .expect("disabled")
1629            .is_none());
1630
1631        config = crate::config::ConfigSet::load(
1632            &crate::environment::Environment::empty(),
1633            Some(&git_dir),
1634            true,
1635        )
1636        .expect("config");
1637        let sparse = SparseCheckout::from_config(&git_dir, &config)
1638            .expect("load")
1639            .expect("enabled");
1640        assert!(sparse.includes("a/x") && sparse.includes("top") && !sparse.includes("b/x"));
1641
1642        fs::remove_file(git_dir.join("info/sparse-checkout")).expect("rm");
1643        assert!(SparseCheckout::from_config(&git_dir, &config)
1644            .expect("missing file")
1645            .is_none());
1646
1647        fs::write(git_dir.join("config"), "[core]\n\tsparseCheckout = maybe\n").expect("config");
1648        let bad = crate::config::ConfigSet::load(
1649            &crate::environment::Environment::empty(),
1650            Some(&git_dir),
1651            true,
1652        )
1653        .expect("config");
1654        assert!(matches!(
1655            SparseCheckout::from_config(&git_dir, &bad),
1656            Err(crate::error::Error::Config(_))
1657        ));
1658    }
1659
1660    #[test]
1661    fn sparse_checkout_marks_and_carries_skip_worktree() {
1662        let sparse = SparseCheckout::parse("/*\n!/*/\n/a/\n", true);
1663        let mut index = index_of(&["a/f", "b/f", "top"]);
1664        sparse.mark_index_entries(&mut index, None);
1665        assert_eq!(
1666            skip_bits(&index),
1667            [
1668                ("a/f".to_owned(), false),
1669                ("b/f".to_owned(), true),
1670                ("top".to_owned(), false)
1671            ]
1672        );
1673        assert_eq!(index.version, 3);
1674
1675        let mut previous = index_of(&["a/f", "a/skipped", "b/f", "b/present"]);
1676        for entry in previous.entries_mut() {
1677            entry.set_skip_worktree(entry.path.as_slice() != b"b/present");
1678        }
1679        previous.entries_mut()[0].set_skip_worktree(false);
1680        let mut rebuilt = index_of(&["a/f", "a/new", "a/skipped", "b/f", "b/new", "b/present"]);
1681        sparse.carry_skip_worktree(&mut rebuilt, &previous);
1682        assert_eq!(
1683            skip_bits(&rebuilt),
1684            [
1685                ("a/f".to_owned(), false),
1686                ("a/new".to_owned(), false),
1687                ("a/skipped".to_owned(), true),
1688                ("b/f".to_owned(), true),
1689                ("b/new".to_owned(), true),
1690                ("b/present".to_owned(), false)
1691            ]
1692        );
1693
1694        let mut conflicted = index_of(&[]);
1695        let mut ours = stage0_entry("b/c");
1696        ours.flags |= 2 << 12;
1697        conflicted.add_or_replace(ours);
1698        let mut resolved = index_of(&["b/c"]);
1699        sparse.carry_skip_worktree(&mut resolved, &conflicted);
1700        assert!(!resolved.entries()[0].skip_worktree());
1701        assert!(sparse.checkout_skips(&index_of(&[]), "b/x"));
1702        assert!(!sparse.checkout_skips(&conflicted, "b/c"));
1703        assert!(!sparse.checkout_skips(&index_of(&["b/x"]), "b/x"));
1704    }
1705
1706    #[test]
1707    fn cone_workspace_and_cone_mode_helpers() {
1708        let content = "/*\n!/*/\n/pkg/\n";
1709        let cone = ConePatterns::try_parse(content).expect("cone");
1710        let ws = ConeWorkspace::from_cone_patterns(&cone);
1711        assert!(!ws.recursive_slash.is_empty());
1712        assert!(cone.path_included("pkg/a"));
1713        assert!(path_in_cone_mode_sparse_checkout(
1714            "pkg/a",
1715            &parse_sparse_checkout_file(content),
1716            true
1717        ));
1718        assert!(path_in_cone_mode_sparse_checkout("any", &[], false));
1719    }
1720
1721    #[test]
1722    fn non_cone_negation_and_glob_patterns() {
1723        let lines = vec!["/*".to_string(), "!/*/".to_string(), "/docs/".to_string()];
1724        assert!(path_in_sparse_checkout_patterns("root.txt", &lines, false));
1725        assert!(path_matches_sparse_patterns("docs/guide", &lines, false));
1726        let neg = NonConePatterns::from_lines(vec!["/*".into(), "!/private/".into()]);
1727        assert!(neg.path_included("public/x"));
1728        assert!(!neg.path_included("private/x"));
1729    }
1730
1731    #[test]
1732    fn cone_workspace_from_directory_list_prunes_children() {
1733        let ws = ConeWorkspace::from_directory_list(&[
1734            "a".to_string(),
1735            "a/b".to_string(),
1736            "c".to_string(),
1737        ]);
1738        let serialized = ws.to_sparse_checkout_file();
1739        assert!(serialized.contains("/*"));
1740        assert!(serialized.contains("/a/") || serialized.contains("a"));
1741    }
1742
1743    #[test]
1744    fn cone_parse_warnings_cover_malformed_patterns() {
1745        let mut warnings = Vec::new();
1746        assert!(ConePatterns::try_parse_with_warnings("foo\n", &mut warnings).is_none());
1747        assert!(!warnings.is_empty());
1748        warnings.clear();
1749        assert!(ConePatterns::try_parse_with_warnings("/*\n!/*/\n/**/\n", &mut warnings).is_none());
1750        warnings.clear();
1751        assert!(
1752            ConePatterns::try_parse_with_warnings("/*\n!/*/\n!/missing/*/\n", &mut warnings)
1753                .is_none()
1754        );
1755        warnings.clear();
1756        assert!(ConePatterns::try_parse_with_warnings("/*\n!/*/\n/bad\n", &mut warnings).is_none());
1757        warnings.clear();
1758        assert!(ConePatterns::try_parse_with_warnings("/*\n!/*/\n/w*/\n", &mut warnings).is_none());
1759    }
1760
1761    #[test]
1762    fn cone_serialization_and_list_directories() {
1763        let cone = ConePatterns::try_parse("/*\n!/*/\n/docs/\n/sub/\n").expect("cone");
1764        let ws = ConeWorkspace::from_cone_patterns(&cone);
1765        let file = ws.to_sparse_checkout_file();
1766        assert!(file.contains("docs") || file.contains("/docs/"));
1767        let listed = ws.list_cone_directories();
1768        assert!(listed
1769            .iter()
1770            .any(|d| d.contains("docs") || d.contains("sub")));
1771    }
1772
1773    #[test]
1774    fn non_cone_glob_and_dup_filter_edge_cases() {
1775        assert!(non_cone_line_matches("*.txt", "dir/file.txt"));
1776        assert!(!non_cone_line_matches("*.txt", "dir/file.md"));
1777        assert_eq!(dup_and_filter_pattern("/foo/\\*/"), "/foo/*/");
1778        assert!(glob_special_unescaped(b"foo*bar"));
1779        assert!(!glob_special_unescaped(b"foo\\*bar"));
1780    }
1781
1782    #[test]
1783    fn clear_skip_worktree_hits_path_found_cache() {
1784        let tmp = write_sparse_repo(true, "/*\n!/*/\n/hidden/\n");
1785        let git_dir = tmp.path().join(".git");
1786        let wt = tmp.path();
1787        fs::create_dir_all(wt.join("visible")).expect("mkdir");
1788        fs::write(wt.join("visible/keep.txt"), "x").expect("write");
1789        fs::create_dir_all(wt.join("hidden")).expect("mkdir hidden");
1790        fs::write(wt.join("hidden/secret.txt"), "y").expect("write");
1791        let mut index = crate::index::Index::new();
1792        for path in ["visible/keep.txt", "hidden/secret.txt"] {
1793            index.add_or_replace(crate::index::IndexEntry {
1794                ctime_sec: 0,
1795                ctime_nsec: 0,
1796                mtime_sec: 0,
1797                mtime_nsec: 0,
1798                dev: 0,
1799                ino: 0,
1800                mode: crate::index::MODE_REGULAR,
1801                uid: 0,
1802                gid: 0,
1803                size: 1,
1804                oid: crate::objects::ObjectId::zero(),
1805                flags: path.len().min(0xFFF) as u16,
1806                flags_extended: Some(0),
1807                path: path.as_bytes().to_vec(),
1808                base_index_pos: 0,
1809            });
1810        }
1811        for e in index.entries_mut() {
1812            e.set_skip_worktree(true);
1813        }
1814        apply_sparse_checkout_skip_worktree(&git_dir, Some(wt), &mut index, false);
1815        clear_skip_worktree_from_present_files(&git_dir, wt, &mut index, None);
1816        assert!(!index.entries()[0].skip_worktree());
1817    }
1818
1819    #[test]
1820    fn cone_directory_inputs_for_add_non_cone_fallback() {
1821        let inputs = cone_directory_inputs_for_add("top\nnested/\n");
1822        assert_eq!(inputs, vec!["top".to_string(), "nested".to_string()]);
1823    }
1824
1825    #[test]
1826    fn path_in_sparse_patterns_empty_and_parent_fallback() {
1827        assert!(path_in_sparse_checkout_patterns("", &["/*".into()], true));
1828        let lines = vec!["/*".to_string(), "!/*/".to_string(), "/pkg/".to_string()];
1829        assert!(path_in_sparse_checkout_patterns(
1830            "pkg/deep/file",
1831            &lines,
1832            true
1833        ));
1834        assert!(!path_in_sparse_checkout_patterns(
1835            "other/file",
1836            &lines,
1837            true
1838        ));
1839    }
1840
1841    #[test]
1842    fn expanded_cone_parent_and_recursive_matching() {
1843        let lines = vec![
1844            "/*".to_string(),
1845            "!/*/".to_string(),
1846            "/sub/".to_string(),
1847            "!/sub/*/".to_string(),
1848            "/sub/dir/".to_string(),
1849        ];
1850        assert!(path_in_sparse_checkout_patterns(
1851            "sub/file.txt",
1852            &lines,
1853            true
1854        ));
1855        assert!(path_in_sparse_checkout_patterns(
1856            "sub/dir/nested",
1857            &lines,
1858            true
1859        ));
1860        assert!(!path_in_sparse_checkout_patterns(
1861            "sub/other/nested",
1862            &lines,
1863            true
1864        ));
1865    }
1866
1867    #[test]
1868    fn build_expanded_cone_skips_redundant_parent_dirs() {
1869        let lines = build_expanded_cone_sparse_checkout_lines(&[
1870            "a".to_string(),
1871            "a/b".to_string(),
1872            "c".to_string(),
1873        ]);
1874        assert!(lines.iter().any(|l| l.contains("/a/") || l == "/a/"));
1875        assert!(lines.iter().any(|l| l.contains("/c/") || l == "/c/"));
1876    }
1877
1878    #[test]
1879    fn cone_workspace_serializes_parent_and_recursive_rules() {
1880        let cone = ConePatterns::try_parse("/*\n!/*/\n/parent/\n/leaf/\n").expect("parse");
1881        let ws = ConeWorkspace::from_cone_patterns(&cone);
1882        let body = ws.to_sparse_checkout_file();
1883        assert!(body.contains("parent") || body.contains("/parent/"));
1884        assert!(body.contains("leaf") || body.contains("/leaf/"));
1885        let listed = ws.list_cone_directories();
1886        assert!(listed.iter().any(|d| d.contains("leaf")));
1887    }
1888
1889    #[test]
1890    fn expanded_cone_parse_parent_recursive_and_escape() {
1891        let lines =
1892            build_expanded_cone_sparse_checkout_lines(&["src".to_string(), "docs".to_string()]);
1893        assert!(path_in_expanded_cone("src/lib.rs", &lines));
1894        let esc = escape_cone_pattern_path("/src");
1895        assert_eq!(unescape_cone_pattern_path(&esc), "/src");
1896        assert!(recursive_set_has_strict_ancestor(
1897            &BTreeSet::from(["/a/b".to_string()]),
1898            "/a/b/c"
1899        ));
1900    }
1901}