1use std::collections::BTreeSet;
7
8use crate::wildmatch::{wildmatch, WM_PATHNAME};
9
10#[derive(Debug, Clone)]
12pub struct NonConePatterns {
13 lines: Vec<String>,
14}
15
16impl NonConePatterns {
17 #[must_use]
19 pub fn from_lines(lines: Vec<String>) -> Self {
20 Self { lines }
21 }
22
23 #[must_use]
25 pub fn lines(&self) -> &[String] {
26 &self.lines
27 }
28
29 #[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 #[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 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 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
129fn 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#[derive(Debug, Clone, Default)]
166pub struct ConePatterns {
167 pub full_cone: bool,
169 pub recursive_slash: BTreeSet<String>,
171 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 #[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 if negated && (rest == "/*" || rest == "/*/") {
208 full_cone = false;
209 continue;
210 }
211 if !negated && rest == "/*" {
212 full_cone = true;
213 continue;
214 }
215
216 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 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 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 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 #[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#[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
458pub 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#[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
495pub 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#[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 ExcludeAll,
555}
556
557impl SparseCheckout {
558 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 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 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 #[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 #[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 #[must_use]
667 pub fn includes_index_path(&self, path: &[u8]) -> bool {
668 self.includes(&String::from_utf8_lossy(path))
669 }
670
671 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 #[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 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 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
744fn 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 dir: String,
765}
766
767fn 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
798pub 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 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#[derive(Debug, Clone, Default)]
853pub struct ConeWorkspace {
854 pub recursive_slash: BTreeSet<String>,
856 pub parent_slash: BTreeSet<String>,
858}
859
860impl ConeWorkspace {
861 #[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 #[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 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 #[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 #[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
995pub 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
1005pub fn sparse_checkout_lines_look_like_expanded_cone(lines: &[String]) -> bool {
1008 lines.len() >= 2 && lines[0] == "/*" && lines[1] == "!/*/"
1009}
1010
1011fn 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 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 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 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#[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
1130pub 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 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
1198fn 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
1232pub 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 out.push(unescape_cone_pattern_path(body));
1261 i += 1;
1262 }
1263 out
1264}
1265
1266#[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 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
1314pub 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 if sparse_checkout_lines_look_like_expanded_cone(patterns) {
1329 return path_in_expanded_cone(path, patterns);
1330 }
1331
1332 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
1348pub 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
1363pub 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 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 let inner = prefix_with_slash.trim_start_matches('/');
1410 if inner.is_empty() {
1411 false
1412 } else if inner == "*" {
1413 let trimmed = path.trim_end_matches('/');
1416 trimmed.contains('/')
1417 } else if inner.contains('*') || inner.contains('?') || inner.contains('[') {
1418 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 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}