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,
168 pub recursive_slash: BTreeSet<String>,
169 pub parent_slash: BTreeSet<String>,
170}
171
172#[derive(Clone, Copy, PartialEq, Eq)]
173enum ConeMatch {
174 Undecided,
175 Matched,
176 MatchedRecursive,
177 NotMatched,
178}
179
180impl ConePatterns {
181 #[must_use]
184 pub fn try_parse_with_warnings(content: &str, warnings: &mut Vec<String>) -> Option<Self> {
185 let lines: Vec<&str> = content
186 .lines()
187 .map(str::trim)
188 .filter(|l| !l.is_empty() && !l.starts_with('#'))
189 .collect();
190
191 let mut full_cone = false;
192 let mut recursive: BTreeSet<String> = BTreeSet::new();
193 let mut parents: BTreeSet<String> = BTreeSet::new();
194
195 for line in lines {
196 let (negated, rest) = if let Some(r) = line.strip_prefix('!') {
197 (true, r)
198 } else {
199 (false, line)
200 };
201
202 if negated && (rest == "/*" || rest == "/*/") {
205 full_cone = false;
206 continue;
207 }
208 if !negated && rest == "/*" {
209 full_cone = true;
210 continue;
211 }
212
213 let stored = rest.strip_suffix('/').unwrap_or(rest);
220 if stored.starts_with('/')
221 && stored.len() > 2
222 && stored.ends_with("/*")
223 && !stored.ends_with("\\*")
224 {
225 if !negated {
226 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
227 warnings.push("warning: disabling cone pattern matching".to_string());
228 return None;
229 }
230 let key = dup_and_filter_pattern(stored);
231 if !recursive.contains(&key) {
232 warnings.push(format!("warning: unrecognized negative pattern: '{rest}'"));
233 warnings.push("warning: disabling cone pattern matching".to_string());
234 return None;
235 }
236 recursive.remove(&key);
237 parents.insert(key);
238 continue;
239 }
240
241 if negated {
242 warnings.push(format!("warning: unrecognized negative pattern: '{rest}'"));
243 warnings.push("warning: disabling cone pattern matching".to_string());
244 return None;
245 }
246
247 if rest == "/*" {
248 continue;
249 }
250
251 if !rest.starts_with('/') {
252 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
253 warnings.push("warning: disabling cone pattern matching".to_string());
254 return None;
255 }
256 if rest.contains("**") {
257 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
258 warnings.push("warning: disabling cone pattern matching".to_string());
259 return None;
260 }
261 if rest.len() < 2 {
262 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
263 warnings.push("warning: disabling cone pattern matching".to_string());
264 return None;
265 }
266
267 let must_be_dir = rest.ends_with('/');
268 let body = rest[1..].trim_end_matches('/');
269 if body.is_empty() {
270 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
271 warnings.push("warning: disabling cone pattern matching".to_string());
272 return None;
273 }
274 if !must_be_dir {
275 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
276 warnings.push("warning: disabling cone pattern matching".to_string());
277 return None;
278 }
279 if glob_special_unescaped(body.as_bytes()) {
280 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
281 warnings.push("warning: disabling cone pattern matching".to_string());
282 return None;
283 }
284
285 let key = dup_and_filter_pattern(&format!("/{body}"));
289 if parents.contains(&key) {
290 warnings.push(format!(
291 "warning: your sparse-checkout file may have issues: pattern '{rest}' is repeated"
292 ));
293 warnings.push("warning: disabling cone pattern matching".to_string());
294 return None;
295 }
296 recursive.insert(key.clone());
297 let key_body = key.trim_start_matches('/');
298 let parts: Vec<&str> = key_body.split('/').collect();
299 for i in 1..parts.len() {
300 let prefix = parts[..i].join("/");
301 parents.insert(format!("/{prefix}"));
302 }
303 }
304
305 Some(ConePatterns {
306 full_cone,
307 recursive_slash: recursive,
308 parent_slash: parents,
309 })
310 }
311
312 #[must_use]
313 pub fn try_parse(content: &str) -> Option<Self> {
314 let mut w = Vec::new();
315 Self::try_parse_with_warnings(content, &mut w)
316 }
317
318 fn recursive_contains_parent(path: &str, recursive: &BTreeSet<String>) -> bool {
319 let mut buf = String::from("/");
320 buf.push_str(path);
321 let mut slash_pos = buf.rfind('/');
322 while let Some(pos) = slash_pos {
323 if pos == 0 {
324 break;
325 }
326 buf.truncate(pos);
327 if recursive.contains(&buf) {
328 return true;
329 }
330 slash_pos = buf.rfind('/');
331 }
332 false
333 }
334
335 fn path_matches_pattern_list(&self, pathname: &str) -> ConeMatch {
337 if self.full_cone {
338 return ConeMatch::Matched;
339 }
340
341 let mut parent_pathname = String::with_capacity(pathname.len() + 2);
342 parent_pathname.push('/');
343 parent_pathname.push_str(pathname);
344
345 let slash_pos = if parent_pathname.ends_with('/') {
346 let sp = parent_pathname.len() - 1;
347 parent_pathname.push('-');
348 sp
349 } else {
350 parent_pathname.rfind('/').unwrap_or(0)
351 };
352
353 if self.recursive_slash.contains(&parent_pathname) {
354 return ConeMatch::MatchedRecursive;
355 }
356
357 if slash_pos == 0 {
358 return ConeMatch::Matched;
359 }
360
361 let parent_key = parent_pathname[..slash_pos].to_string();
362 if self.parent_slash.contains(&parent_key) {
363 return ConeMatch::Matched;
364 }
365
366 if Self::recursive_contains_parent(pathname, &self.recursive_slash) {
367 return ConeMatch::MatchedRecursive;
368 }
369
370 ConeMatch::NotMatched
371 }
372
373 #[must_use]
375 pub fn path_included(&self, path: &str) -> bool {
376 if path.is_empty() {
377 return true;
378 }
379
380 let bytes = path.as_bytes();
381 let mut end = bytes.len();
382 let mut match_result = ConeMatch::Undecided;
383
384 while end > 0 && match_result == ConeMatch::Undecided {
385 let slice = path.get(..end).unwrap_or("");
386 match_result = self.path_matches_pattern_list(slice);
387
388 let mut slash = end.saturating_sub(1);
389 while slash > 0 && bytes[slash] != b'/' {
390 slash -= 1;
391 }
392 end = if bytes.get(slash) == Some(&b'/') {
393 slash
394 } else {
395 0
396 };
397 }
398
399 matches!(
400 match_result,
401 ConeMatch::Matched | ConeMatch::MatchedRecursive
402 )
403 }
404}
405
406#[must_use]
408pub fn load_sparse_checkout(
409 git_dir: &std::path::Path,
410 cone_config: bool,
411) -> (bool, Option<ConePatterns>, NonConePatterns) {
412 let mut w = Vec::new();
413 load_sparse_checkout_with_warnings(git_dir, cone_config, &mut w)
414}
415
416pub fn load_sparse_checkout_with_warnings(
418 git_dir: &std::path::Path,
419 cone_config: bool,
420 warnings: &mut Vec<String>,
421) -> (bool, Option<ConePatterns>, NonConePatterns) {
422 let path = git_dir.join("info").join("sparse-checkout");
423 let Ok(content) = std::fs::read_to_string(&path) else {
424 return (false, None, NonConePatterns { lines: Vec::new() });
425 };
426 let non_cone = NonConePatterns::parse(&content);
427 if !cone_config {
428 return (false, None, non_cone);
429 }
430 match ConePatterns::try_parse_with_warnings(&content, warnings) {
431 Some(cone) => (true, Some(cone), non_cone),
432 None => (false, None, non_cone),
433 }
434}
435
436#[must_use]
438pub fn path_in_sparse_checkout(
439 path: &str,
440 cone_config: bool,
441 cone: Option<&ConePatterns>,
442 non_cone: &NonConePatterns,
443 work_tree: Option<&std::path::Path>,
444) -> bool {
445 if cone_config {
446 if let Some(c) = cone {
447 return c.path_included(path);
448 }
449 }
450 crate::ignore::path_in_sparse_checkout(path, non_cone.lines(), work_tree)
451}
452
453pub fn apply_sparse_checkout_skip_worktree(
466 git_dir: &std::path::Path,
467 work_tree: Option<&std::path::Path>,
468 index: &mut crate::index::Index,
469 skip_sparse_checkout: bool,
470) {
471 if skip_sparse_checkout {
472 return;
473 }
474
475 let config = crate::config::ConfigSet::load(Some(git_dir), true)
476 .unwrap_or_else(|_| crate::config::ConfigSet::new());
477 let sparse_enabled = config
478 .get_bool("core.sparsecheckout")
479 .and_then(|r| r.ok())
480 .unwrap_or(false);
481
482 if !sparse_enabled {
483 return;
484 }
485
486 let cone_config = config
491 .get_bool("core.sparsecheckoutcone")
492 .and_then(|r| r.ok())
493 .unwrap_or(false);
494
495 let mut warnings = Vec::new();
496 let (_cone_ok, _cone_loaded, non_cone) =
497 load_sparse_checkout_with_warnings(git_dir, cone_config, &mut warnings);
498 for line in warnings {
499 eprintln!("{line}");
500 }
501
502 let sparse_path = git_dir.join("info").join("sparse-checkout");
503 let file_content = std::fs::read_to_string(&sparse_path).unwrap_or_default();
504 let sparse_lines = parse_sparse_checkout_file(&file_content);
505
506 let cone_struct = if cone_config {
509 ConePatterns::try_parse(&file_content)
510 } else {
511 None
512 };
513 let effective_cone = cone_config && cone_struct.is_some();
514
515 let sparse_file_exists = sparse_path.is_file();
519 let exclude_all = sparse_file_exists && sparse_lines.is_empty();
520
521 let mut any_skip = false;
522 for entry in &mut index.entries {
523 if entry.stage() != 0 {
524 continue;
525 }
526 let path_str = String::from_utf8_lossy(&entry.path);
527 let included = if exclude_all {
528 false
529 } else if effective_cone {
530 path_in_sparse_checkout(
531 path_str.as_ref(),
532 true,
533 cone_struct.as_ref(),
534 &non_cone,
535 work_tree,
536 )
537 } else {
538 crate::ignore::path_in_sparse_checkout(path_str.as_ref(), non_cone.lines(), work_tree)
539 };
540 entry.set_skip_worktree(!included);
541 if !included {
542 any_skip = true;
543 }
544 }
545
546 if any_skip && index.version < 3 {
547 index.version = 3;
548 }
549}
550
551fn max_common_dir_prefix(path1: &str, path2: &str) -> usize {
553 let b1 = path1.as_bytes();
554 let b2 = path2.as_bytes();
555 let mut common_prefix = 0usize;
556 let mut i = 0usize;
557 while i < b1.len() && i < b2.len() {
558 if b1[i] != b2[i] {
559 break;
560 }
561 if b1[i] == b'/' {
562 common_prefix = i + 1;
563 }
564 i += 1;
565 }
566 common_prefix
567}
568
569struct PathFoundData {
570 dir: String,
572}
573
574fn path_found(path: &str, data: &mut PathFoundData) -> bool {
576 let pb = path.as_bytes();
577 let db = data.dir.as_bytes();
578 if !db.is_empty() && pb.len() >= db.len() && pb[..db.len()] == *db {
579 return false;
580 }
581
582 if std::fs::symlink_metadata(std::path::Path::new(path)).is_ok() {
583 return true;
584 }
585
586 let common_prefix = max_common_dir_prefix(path, &data.dir);
587 data.dir.truncate(common_prefix);
588
589 loop {
590 let rest = &path[data.dir.len()..];
591 if let Some(rel_slash) = rest.find('/') {
592 data.dir.push_str(&rest[..=rel_slash]);
593 if std::fs::symlink_metadata(std::path::Path::new(&data.dir)).is_err() {
594 return false;
595 }
596 } else {
597 data.dir.push_str(rest);
598 data.dir.push('/');
599 break;
600 }
601 }
602 false
603}
604
605pub fn clear_skip_worktree_from_present_files(
611 git_dir: &std::path::Path,
612 work_tree: &std::path::Path,
613 index: &mut crate::index::Index,
614) {
615 let config = crate::config::ConfigSet::load(Some(git_dir), true)
616 .unwrap_or_else(|_| crate::config::ConfigSet::new());
617 let sparse_enabled = config
618 .get_bool("core.sparsecheckout")
619 .and_then(|r| r.ok())
620 .unwrap_or(false);
621 if !sparse_enabled {
622 return;
623 }
624 if config
625 .get_bool("sparse.expectfilesoutsideofpatterns")
626 .and_then(|r| r.ok())
627 .unwrap_or(false)
628 {
629 return;
630 }
631
632 let mut found = PathFoundData { dir: String::new() };
633 for entry in &mut index.entries {
634 if entry.stage() != 0 || !entry.skip_worktree() {
635 continue;
636 }
637 if entry.assume_unchanged() {
640 continue;
641 }
642 let rel = String::from_utf8_lossy(&entry.path);
643 let abs = work_tree.join(rel.as_ref());
644 let abs_str = abs.to_string_lossy().into_owned();
645 if path_found(&abs_str, &mut found) {
646 entry.set_skip_worktree(false);
647 }
648 }
649}
650
651#[derive(Debug, Clone, Default)]
653pub struct ConeWorkspace {
654 pub recursive_slash: BTreeSet<String>,
655 pub parent_slash: BTreeSet<String>,
656}
657
658impl ConeWorkspace {
659 #[must_use]
661 pub fn from_cone_patterns(cp: &ConePatterns) -> Self {
662 Self {
663 recursive_slash: cp.recursive_slash.clone(),
664 parent_slash: cp.parent_slash.clone(),
665 }
666 }
667
668 #[must_use]
670 pub fn from_directory_list(dirs: &[String]) -> Self {
671 let mut pruned: Vec<String> = dirs
672 .iter()
673 .map(|s| s.trim_start_matches('/').trim_end_matches('/').to_string())
674 .filter(|s| !s.is_empty())
675 .collect();
676 pruned.sort();
677 let mut kept: Vec<String> = Vec::new();
678 for d in pruned {
679 if kept
680 .iter()
681 .any(|p| d.starts_with(p) && d.as_bytes().get(p.len()) == Some(&b'/'))
682 {
683 continue;
684 }
685 kept.retain(|k| !(k.starts_with(&d) && k.as_bytes().get(d.len()) == Some(&b'/')));
686 kept.push(d);
687 }
688 let mut ws = ConeWorkspace::default();
689 for d in kept {
690 ws.insert_directory(&d);
691 }
692 ws
693 }
694
695 pub fn insert_directory(&mut self, rel: &str) {
697 let rel = rel.trim_start_matches('/');
698 let rel = rel.trim_end_matches('/');
699 if rel.is_empty() {
700 return;
701 }
702 let key = format!("/{rel}");
703 if self.parent_slash.contains(&key) {
704 return;
705 }
706 self.recursive_slash.insert(key.clone());
707 let parts: Vec<&str> = rel.split('/').collect();
708 for i in 1..parts.len() {
709 let prefix = parts[..i].join("/");
710 self.parent_slash.insert(format!("/{prefix}"));
711 }
712 }
713
714 fn recursive_contains_parent(path_slash: &str, recursive: &BTreeSet<String>) -> bool {
715 let mut buf = String::from(path_slash);
716 let mut slash_pos = buf.rfind('/');
717 while let Some(pos) = slash_pos {
718 if pos == 0 {
719 break;
720 }
721 buf.truncate(pos);
722 if recursive.contains(&buf) {
723 return true;
724 }
725 slash_pos = buf.rfind('/');
726 }
727 false
728 }
729
730 #[must_use]
732 pub fn to_sparse_checkout_file(&self) -> String {
733 let mut parent_only: Vec<&String> = self
734 .parent_slash
735 .iter()
736 .filter(|p| {
737 !self.recursive_slash.contains(*p)
738 && !Self::recursive_contains_parent(p, &self.recursive_slash)
739 })
740 .collect();
741 parent_only.sort();
742
743 let mut out = String::new();
744 out.push_str("/*\n!/*/\n");
745
746 for p in parent_only {
747 let esc = escape_cone_path_component(p);
748 out.push_str(&esc);
749 out.push_str("/\n!");
750 out.push_str(&esc);
751 out.push_str("/*/\n");
752 }
753
754 let mut rec_only: Vec<&String> = self
755 .recursive_slash
756 .iter()
757 .filter(|p| !Self::recursive_contains_parent(p, &self.recursive_slash))
758 .collect();
759 rec_only.sort();
760
761 for p in rec_only {
762 let esc = escape_cone_path_component(p);
763 out.push_str(&esc);
764 out.push_str("/\n");
765 }
766 out
767 }
768
769 #[must_use]
771 pub fn list_cone_directories(&self) -> Vec<String> {
772 let mut v: Vec<String> = self
773 .recursive_slash
774 .iter()
775 .map(|s| s.trim_start_matches('/').to_string())
776 .collect();
777 v.sort();
778 v
779 }
780}
781
782fn escape_cone_path_component(path_with_leading_slash: &str) -> String {
783 let mut out = String::new();
784 for ch in path_with_leading_slash.chars() {
785 if matches!(ch, '*' | '?' | '[' | '\\') {
786 out.push('\\');
787 }
788 out.push(ch);
789 }
790 out
791}
792
793pub fn parse_sparse_checkout_file(content: &str) -> Vec<String> {
795 content
796 .lines()
797 .map(|l| l.trim())
798 .filter(|l| !l.is_empty() && !l.starts_with('#'))
799 .map(String::from)
800 .collect()
801}
802
803pub fn sparse_checkout_lines_look_like_expanded_cone(lines: &[String]) -> bool {
806 lines.len() >= 2 && lines[0] == "/*" && lines[1] == "!/*/"
807}
808
809fn parse_expanded_cone_parent_recursive(lines: &[String]) -> Option<(Vec<String>, Vec<String>)> {
812 if !sparse_checkout_lines_look_like_expanded_cone(lines) {
813 return None;
814 }
815 let mut parents = Vec::new();
816 let mut recursive = Vec::new();
817 let mut i = 2usize;
818 while i + 1 < lines.len() {
819 let a = &lines[i];
820 let b = &lines[i + 1];
821 if !a.starts_with('/') || !a.ends_with('/') || !b.starts_with('!') {
822 break;
823 }
824 let inner_a = a.trim_start_matches('/').trim_end_matches('/');
825 let expected_neg = format!("!/{inner_a}/*/");
826 if b != &expected_neg {
827 break;
828 }
829 parents.push(unescape_cone_pattern_path(inner_a));
831 i += 2;
832 }
833 while i < lines.len() {
834 let line = &lines[i];
835 if line.starts_with('!') {
836 return None;
837 }
838 if !line.starts_with('/') || !line.ends_with('/') {
839 return None;
840 }
841 let body = line.trim_start_matches('/').trim_end_matches('/');
842 if body.is_empty() {
843 return None;
844 }
845 recursive.push(unescape_cone_pattern_path(body));
846 i += 1;
847 }
848 Some((parents, recursive))
849}
850
851fn path_in_expanded_cone(path: &str, lines: &[String]) -> bool {
852 let Some((parents, recursive)) = parse_expanded_cone_parent_recursive(lines) else {
853 return false;
854 };
855 let raw = path.trim_start_matches('/');
856 let is_directory_path = raw.ends_with('/');
857 let path = raw.trim_end_matches('/');
858
859 if !path.contains('/') {
860 if !is_directory_path {
864 return true;
865 }
866 if parents.is_empty() && recursive.is_empty() {
867 return false;
868 }
869 return parents.iter().any(|p| p == path)
870 || recursive
871 .iter()
872 .any(|r| r == path || r.starts_with(&format!("{path}/")));
873 }
874
875 for r in &recursive {
876 if path == *r || path.starts_with(&format!("{r}/")) {
877 return true;
878 }
879 }
880
881 for p in &parents {
882 let p_slash = format!("{}/", p);
883 if path == *p {
884 return true;
885 }
886 if !path.starts_with(&p_slash) {
887 continue;
888 }
889 let rest = &path[p_slash.len()..];
890 let Some(slash_pos) = rest.find('/') else {
891 if !is_directory_path {
897 return true;
898 }
899 let combined = format!("{}/{}", p, rest);
900 return recursive
901 .iter()
902 .any(|r| r == &combined || r.starts_with(&format!("{combined}/")));
903 };
904 let first = &rest[..slash_pos];
905 let combined = format!("{}/{}", p, first);
906 for r in &recursive {
907 let under_r = path == *r || path.starts_with(&format!("{r}/"));
908 let r_covers = r == &combined || r.starts_with(&format!("{combined}/"));
909 if r_covers && under_r {
910 return true;
911 }
912 }
913 }
914
915 false
916}
917
918#[must_use]
924pub fn effective_cone_mode_for_sparse_file(cone_config: bool, lines: &[String]) -> bool {
925 cone_config && sparse_checkout_lines_look_like_expanded_cone(lines)
926}
927
928pub fn build_expanded_cone_sparse_checkout_lines(dirs: &[String]) -> Vec<String> {
934 let mut recursive: BTreeSet<String> = BTreeSet::new();
935 for d in dirs {
936 let t = d.trim().trim_start_matches('/').trim_end_matches('/');
937 if t.is_empty() {
938 continue;
939 }
940 recursive.insert(format!("/{t}"));
941 }
942
943 let mut parents: BTreeSet<String> = BTreeSet::new();
944 for r in &recursive {
945 let mut cur = r.clone();
946 loop {
947 let Some(slash) = cur.rfind('/') else {
948 break;
949 };
950 if slash == 0 {
951 break;
952 }
953 cur.truncate(slash);
954 parents.insert(cur.clone());
955 }
956 }
957
958 let mut out = vec!["/*".to_owned(), "!/*/".to_owned()];
959
960 for p in parents.iter() {
961 if recursive.contains(p) {
962 continue;
963 }
964 if recursive_set_has_strict_ancestor(&recursive, p) {
965 continue;
966 }
967 let esc = escape_cone_pattern_path(p);
968 out.push(format!("{esc}/"));
969 out.push(format!("!{esc}/*/"));
970 }
971
972 for r in recursive.iter() {
973 if recursive_set_has_strict_ancestor(&recursive, r) {
974 continue;
975 }
976 let esc = escape_cone_pattern_path(r);
977 out.push(format!("{esc}/"));
978 }
979
980 out
981}
982
983fn escape_cone_pattern_path(path_with_leading_slash: &str) -> String {
984 let mut out = String::with_capacity(path_with_leading_slash.len() + 8);
987 for ch in path_with_leading_slash.chars() {
988 match ch {
989 '\\' | '[' | '*' | '?' | '#' => {
990 out.push('\\');
991 out.push(ch);
992 }
993 _ => out.push(ch),
994 }
995 }
996 out
997}
998
999fn unescape_cone_pattern_path(escaped: &str) -> String {
1002 let mut out = String::with_capacity(escaped.len());
1003 let mut chars = escaped.chars().peekable();
1004 while let Some(ch) = chars.next() {
1005 if ch == '\\' {
1006 if let Some(&next) = chars.peek() {
1007 if matches!(next, '\\' | '[' | '*' | '?' | '#') {
1008 out.push(next);
1009 chars.next();
1010 continue;
1011 }
1012 }
1013 }
1014 out.push(ch);
1015 }
1016 out
1017}
1018
1019fn recursive_set_has_strict_ancestor(recursive: &BTreeSet<String>, path: &str) -> bool {
1020 let mut cur = path.to_string();
1021 loop {
1022 let Some(slash) = cur.rfind('/') else {
1023 break;
1024 };
1025 if slash == 0 {
1026 break;
1027 }
1028 cur.truncate(slash);
1029 if recursive.contains(&cur) {
1030 return true;
1031 }
1032 }
1033 false
1034}
1035
1036#[must_use]
1041pub fn parse_expanded_cone_user_directories(lines: &[String]) -> Vec<String> {
1042 if !sparse_checkout_lines_look_like_expanded_cone(lines) {
1043 return Vec::new();
1044 }
1045 let mut i = 2usize;
1046 let mut out = Vec::new();
1047 while i < lines.len() {
1048 let line = &lines[i];
1049 if line.starts_with('!') {
1050 i += 1;
1051 continue;
1052 }
1053 if !line.starts_with('/') || !line.ends_with('/') {
1054 i += 1;
1055 continue;
1056 }
1057 let body = line
1058 .trim_start_matches('/')
1059 .trim_end_matches('/')
1060 .to_string();
1061 let expected_neg = format!("!/{body}/*/");
1062 if i + 1 < lines.len() && lines[i + 1] == expected_neg {
1063 out.push(body);
1064 i += 2;
1065 continue;
1066 }
1067 i += 1;
1068 }
1069 out
1070}
1071
1072pub fn parse_expanded_cone_recursive_dirs(lines: &[String]) -> Vec<String> {
1075 if !sparse_checkout_lines_look_like_expanded_cone(lines) {
1076 return Vec::new();
1077 }
1078 let mut i = 2usize;
1079 let mut out = Vec::new();
1080 while i < lines.len() {
1081 let line = &lines[i];
1082 if line.starts_with('!') {
1083 i += 1;
1084 continue;
1085 }
1086 if !line.ends_with('/') || !line.starts_with('/') {
1087 i += 1;
1088 continue;
1089 }
1090 let trimmed = line.trim_end_matches('/');
1091 let body = trimmed.trim_start_matches('/');
1092 let esc = escape_cone_pattern_path(trimmed);
1093 let expected_neg = format!("!{esc}/*/");
1094 if i + 1 < lines.len() && lines[i + 1] == expected_neg {
1095 i += 2;
1096 continue;
1097 }
1098 out.push(unescape_cone_pattern_path(body));
1101 i += 1;
1102 }
1103 out
1104}
1105
1106#[must_use]
1115pub fn cone_directory_inputs_for_add(content: &str) -> Vec<String> {
1116 let lines: Vec<String> = parse_sparse_checkout_file(content);
1117 if sparse_checkout_lines_look_like_expanded_cone(&lines) {
1118 let recursive = parse_expanded_cone_recursive_dirs(&lines);
1119 if !recursive.is_empty() {
1120 return recursive;
1121 }
1122 if lines.len() == 2 {
1123 return Vec::new();
1124 }
1125 return lines[2..]
1129 .iter()
1130 .map(|s| {
1131 s.trim()
1132 .trim_start_matches('/')
1133 .trim_end_matches('/')
1134 .to_string()
1135 })
1136 .filter(|s| !s.is_empty())
1137 .collect();
1138 }
1139 if let Some(cp) = ConePatterns::try_parse(content) {
1140 return ConeWorkspace::from_cone_patterns(&cp).list_cone_directories();
1141 }
1142 lines
1143 .iter()
1144 .map(|s| {
1145 s.trim()
1146 .trim_start_matches('/')
1147 .trim_end_matches('/')
1148 .to_string()
1149 })
1150 .filter(|s| !s.is_empty())
1151 .collect()
1152}
1153
1154pub fn path_in_sparse_checkout_patterns(path: &str, patterns: &[String], cone_mode: bool) -> bool {
1162 if path.is_empty() || patterns.is_empty() {
1163 return true;
1164 }
1165
1166 if sparse_checkout_lines_look_like_expanded_cone(patterns) {
1169 return path_in_expanded_cone(path, patterns);
1170 }
1171
1172 let use_cone_prefix = cone_mode;
1174
1175 let mut end = path.len();
1176 while end > 0 {
1177 if path_matches_sparse_patterns(&path[..end], patterns, use_cone_prefix) {
1178 return true;
1179 }
1180 let Some(slash) = path[..end].rfind('/') else {
1181 break;
1182 };
1183 end = slash;
1184 }
1185 false
1186}
1187
1188pub fn path_in_cone_mode_sparse_checkout(
1193 path: &str,
1194 patterns: &[String],
1195 cone_enabled: bool,
1196) -> bool {
1197 if !cone_enabled || patterns.is_empty() {
1198 return true;
1199 }
1200 path_in_sparse_checkout_patterns(path, patterns, true)
1201}
1202
1203pub fn path_matches_sparse_patterns(path: &str, patterns: &[String], cone_mode: bool) -> bool {
1206 let expanded_cone = sparse_checkout_lines_look_like_expanded_cone(patterns);
1207 if expanded_cone {
1208 return path_in_expanded_cone(path, patterns);
1209 }
1210 let raw_cone_prefix = cone_mode && !expanded_cone;
1213
1214 if raw_cone_prefix {
1215 if !path.contains('/') {
1216 return true;
1217 }
1218
1219 for pattern in patterns {
1220 let prefix = pattern.trim_end_matches('/');
1221 if path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/') {
1222 return true;
1223 }
1224 if path == prefix {
1225 return true;
1226 }
1227 }
1228 return false;
1229 }
1230
1231 let mut included = false;
1232 for raw_pattern in patterns {
1233 let pattern = raw_pattern.trim();
1234 if pattern.is_empty() || pattern.starts_with('#') {
1235 continue;
1236 }
1237
1238 let (negated, core_pattern) = if let Some(rest) = pattern.strip_prefix('!') {
1239 (true, rest)
1240 } else {
1241 (false, pattern)
1242 };
1243 if core_pattern.is_empty() || core_pattern == "/" {
1244 continue;
1245 }
1246
1247 let matches = if let Some(prefix_with_slash) = core_pattern.strip_suffix('/') {
1248 let inner = prefix_with_slash.trim_start_matches('/');
1250 if inner.is_empty() {
1251 false
1252 } else if inner == "*" {
1253 let trimmed = path.trim_end_matches('/');
1256 trimmed.contains('/')
1257 } else if inner.contains('*') || inner.contains('?') || inner.contains('[') {
1258 let pat = format!("{prefix_with_slash}/");
1260 let text = format!("/{path}/");
1261 wildmatch(pat.as_bytes(), text.as_bytes(), WM_PATHNAME)
1262 } else {
1263 path == inner || path.starts_with(&format!("{inner}/"))
1264 }
1265 } else if core_pattern.starts_with('/') {
1266 let text = format!("/{}", path.trim_start_matches('/'));
1268 wildmatch(core_pattern.as_bytes(), text.as_bytes(), WM_PATHNAME)
1269 } else {
1270 wildmatch(core_pattern.as_bytes(), path.as_bytes(), WM_PATHNAME)
1271 };
1272
1273 if matches {
1274 included = !negated;
1275 }
1276 }
1277
1278 included
1279}
1280
1281#[cfg(test)]
1282mod path_in_expanded_cone_tests {
1283 use super::path_in_sparse_checkout_patterns;
1284
1285 #[test]
1286 fn root_only_cone_includes_files_not_top_level_dirs() {
1287 let lines = vec!["/*".to_string(), "!/*/".to_string()];
1288 assert!(path_in_sparse_checkout_patterns("file.1.txt", &lines, true));
1289 assert!(!path_in_sparse_checkout_patterns("a/", &lines, true));
1290 assert!(!path_in_sparse_checkout_patterns("d/", &lines, true));
1291 }
1292
1293 #[test]
1294 fn expanded_cone_with_d_includes_d_tree_not_sibling_a() {
1295 let lines = vec!["/*".to_string(), "!/*/".to_string(), "/d/".to_string()];
1296 assert!(path_in_sparse_checkout_patterns("file.1.txt", &lines, true));
1297 assert!(path_in_sparse_checkout_patterns("d/", &lines, true));
1298 assert!(!path_in_sparse_checkout_patterns("a/", &lines, true));
1299 assert!(path_in_sparse_checkout_patterns(
1300 "d/e/file.1.txt",
1301 &lines,
1302 true
1303 ));
1304 }
1305}
1306
1307#[cfg(test)]
1308mod cone_directory_inputs_for_add_tests {
1309 use super::cone_directory_inputs_for_add;
1310
1311 #[test]
1312 fn expanded_header_with_non_cone_body_preserves_literal_dir() {
1313 let content = "/*\n!/*/\ndir\n";
1314 assert_eq!(
1315 cone_directory_inputs_for_add(content),
1316 vec!["dir".to_string()]
1317 );
1318 }
1319
1320 #[test]
1321 fn pure_expanded_cone_uses_recursive_dirs_only() {
1322 let content = "/*\n!/*/\n/sub/\n";
1323 assert_eq!(
1324 cone_directory_inputs_for_add(content),
1325 vec!["sub".to_string()]
1326 );
1327 }
1328}