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 glob_special_unescaped(name: &[u8]) -> bool {
63 let mut i = 0usize;
64 while i < name.len() {
65 if name[i] == b'\\' {
66 i += 2;
67 continue;
68 }
69 if matches!(name[i], b'*' | b'?' | b'[') {
70 return true;
71 }
72 i += 1;
73 }
74 false
75}
76
77fn sparse_glob_match_star_crosses_slash(pattern: &[u8], text: &[u8]) -> bool {
78 if pattern.contains(&b'[') || pattern.contains(&b'\\') {
81 return wildmatch(pattern, text, 0);
82 }
83 let (mut pi, mut ti) = (0usize, 0usize);
84 let (mut star_p, mut star_t) = (usize::MAX, 0usize);
85 while ti < text.len() {
86 if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == text[ti]) {
87 pi += 1;
88 ti += 1;
89 } else if pi < pattern.len() && pattern[pi] == b'*' {
90 star_p = pi;
91 star_t = ti;
92 pi += 1;
93 } else if star_p != usize::MAX {
94 pi = star_p + 1;
95 star_t += 1;
96 ti = star_t;
97 } else {
98 return false;
99 }
100 }
101 while pi < pattern.len() && pattern[pi] == b'*' {
102 pi += 1;
103 }
104 pi == pattern.len()
105}
106
107fn sparse_pattern_matches_git_non_cone(pattern: &str, path: &str) -> bool {
109 let pat = pattern.trim();
110 if pat.is_empty() {
111 return false;
112 }
113
114 let anchored = pat.starts_with('/');
115 let pat = pat.trim_start_matches('/');
116
117 if let Some(dir) = pat.strip_suffix('/') {
118 if anchored && dir == "*" {
119 return path.contains('/');
120 }
121 if anchored {
122 return path == dir || path.starts_with(&format!("{dir}/"));
123 }
124 return path == dir
125 || path.starts_with(&format!("{dir}/"))
126 || path.split('/').any(|component| component == dir);
127 }
128
129 if anchored {
130 return sparse_glob_match_star_crosses_slash(pat.as_bytes(), path.as_bytes());
131 }
132 sparse_glob_match_star_crosses_slash(pat.as_bytes(), path.as_bytes())
133 || path.rsplit('/').next().is_some_and(|base| {
134 sparse_glob_match_star_crosses_slash(pat.as_bytes(), base.as_bytes())
135 })
136}
137
138fn non_cone_line_matches(pattern: &str, path: &str) -> bool {
139 sparse_pattern_matches_git_non_cone(pattern, path)
140}
141
142#[derive(Debug, Clone, Default)]
144pub struct ConePatterns {
145 pub full_cone: bool,
146 pub recursive_slash: BTreeSet<String>,
147 pub parent_slash: BTreeSet<String>,
148}
149
150#[derive(Clone, Copy, PartialEq, Eq)]
151enum ConeMatch {
152 Undecided,
153 Matched,
154 MatchedRecursive,
155 NotMatched,
156}
157
158impl ConePatterns {
159 #[must_use]
162 pub fn try_parse_with_warnings(content: &str, warnings: &mut Vec<String>) -> Option<Self> {
163 let lines: Vec<&str> = content
164 .lines()
165 .map(str::trim)
166 .filter(|l| !l.is_empty() && !l.starts_with('#'))
167 .collect();
168
169 let mut full_cone = false;
170 let mut recursive: BTreeSet<String> = BTreeSet::new();
171 let mut parents: BTreeSet<String> = BTreeSet::new();
172
173 for line in lines {
174 let (negated, rest) = if let Some(r) = line.strip_prefix('!') {
175 (true, r)
176 } else {
177 (false, line)
178 };
179
180 if negated && (rest == "/*" || rest == "/*/") {
183 full_cone = false;
184 continue;
185 }
186 if !negated && rest == "/*" {
187 full_cone = true;
188 continue;
189 }
190
191 if negated && rest.ends_with("/*/") && rest.starts_with('/') && rest.len() > 4 {
192 let inner = &rest[1..rest.len() - 3];
193 if inner.is_empty()
194 || inner.contains('/')
195 || glob_special_unescaped(inner.as_bytes())
196 {
197 warnings.push(format!("warning: unrecognized negative pattern: '{rest}'"));
198 warnings.push("warning: disabling cone pattern matching".to_string());
199 return None;
200 }
201 let key = format!("/{inner}");
202 if !recursive.contains(&key) {
203 warnings.push(format!("warning: unrecognized negative pattern: '{rest}'"));
204 warnings.push("warning: disabling cone pattern matching".to_string());
205 return None;
206 }
207 recursive.remove(&key);
208 parents.insert(key);
209 continue;
210 }
211
212 if negated {
213 warnings.push(format!("warning: unrecognized negative pattern: '{rest}'"));
214 warnings.push("warning: disabling cone pattern matching".to_string());
215 return None;
216 }
217
218 if rest == "/*" {
219 continue;
220 }
221
222 if !rest.starts_with('/') {
223 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
224 warnings.push("warning: disabling cone pattern matching".to_string());
225 return None;
226 }
227 if rest.contains("**") {
228 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
229 warnings.push("warning: disabling cone pattern matching".to_string());
230 return None;
231 }
232 if rest.len() < 2 {
233 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
234 warnings.push("warning: disabling cone pattern matching".to_string());
235 return None;
236 }
237
238 let must_be_dir = rest.ends_with('/');
239 let body = rest[1..].trim_end_matches('/');
240 if body.is_empty() {
241 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
242 warnings.push("warning: disabling cone pattern matching".to_string());
243 return None;
244 }
245 if !must_be_dir {
246 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
247 warnings.push("warning: disabling cone pattern matching".to_string());
248 return None;
249 }
250 if glob_special_unescaped(body.as_bytes()) {
251 warnings.push(format!("warning: unrecognized pattern: '{rest}'"));
252 warnings.push("warning: disabling cone pattern matching".to_string());
253 return None;
254 }
255
256 let key = format!("/{body}");
257 if parents.contains(&key) {
258 warnings.push(format!(
259 "warning: your sparse-checkout file may have issues: pattern '{rest}' is repeated"
260 ));
261 warnings.push("warning: disabling cone pattern matching".to_string());
262 return None;
263 }
264 recursive.insert(key.clone());
265 let parts: Vec<&str> = body.split('/').collect();
266 for i in 1..parts.len() {
267 let prefix = parts[..i].join("/");
268 parents.insert(format!("/{prefix}"));
269 }
270 }
271
272 Some(ConePatterns {
273 full_cone,
274 recursive_slash: recursive,
275 parent_slash: parents,
276 })
277 }
278
279 #[must_use]
280 pub fn try_parse(content: &str) -> Option<Self> {
281 let mut w = Vec::new();
282 Self::try_parse_with_warnings(content, &mut w)
283 }
284
285 fn recursive_contains_parent(path: &str, recursive: &BTreeSet<String>) -> bool {
286 let mut buf = String::from("/");
287 buf.push_str(path);
288 let mut slash_pos = buf.rfind('/');
289 while let Some(pos) = slash_pos {
290 if pos == 0 {
291 break;
292 }
293 buf.truncate(pos);
294 if recursive.contains(&buf) {
295 return true;
296 }
297 slash_pos = buf.rfind('/');
298 }
299 false
300 }
301
302 fn path_matches_pattern_list(&self, pathname: &str) -> ConeMatch {
304 if self.full_cone {
305 return ConeMatch::Matched;
306 }
307
308 let mut parent_pathname = String::with_capacity(pathname.len() + 2);
309 parent_pathname.push('/');
310 parent_pathname.push_str(pathname);
311
312 let slash_pos = if parent_pathname.ends_with('/') {
313 let sp = parent_pathname.len() - 1;
314 parent_pathname.push('-');
315 sp
316 } else {
317 parent_pathname.rfind('/').unwrap_or(0)
318 };
319
320 if self.recursive_slash.contains(&parent_pathname) {
321 return ConeMatch::MatchedRecursive;
322 }
323
324 if slash_pos == 0 {
325 return ConeMatch::Matched;
326 }
327
328 let parent_key = parent_pathname[..slash_pos].to_string();
329 if self.parent_slash.contains(&parent_key) {
330 return ConeMatch::Matched;
331 }
332
333 if Self::recursive_contains_parent(pathname, &self.recursive_slash) {
334 return ConeMatch::MatchedRecursive;
335 }
336
337 ConeMatch::NotMatched
338 }
339
340 #[must_use]
342 pub fn path_included(&self, path: &str) -> bool {
343 if path.is_empty() {
344 return true;
345 }
346
347 let bytes = path.as_bytes();
348 let mut end = bytes.len();
349 let mut match_result = ConeMatch::Undecided;
350
351 while end > 0 && match_result == ConeMatch::Undecided {
352 let slice = path.get(..end).unwrap_or("");
353 match_result = self.path_matches_pattern_list(slice);
354
355 let mut slash = end.saturating_sub(1);
356 while slash > 0 && bytes[slash] != b'/' {
357 slash -= 1;
358 }
359 end = if bytes.get(slash) == Some(&b'/') {
360 slash
361 } else {
362 0
363 };
364 }
365
366 matches!(
367 match_result,
368 ConeMatch::Matched | ConeMatch::MatchedRecursive
369 )
370 }
371}
372
373#[must_use]
375pub fn load_sparse_checkout(
376 git_dir: &std::path::Path,
377 cone_config: bool,
378) -> (bool, Option<ConePatterns>, NonConePatterns) {
379 let mut w = Vec::new();
380 load_sparse_checkout_with_warnings(git_dir, cone_config, &mut w)
381}
382
383pub fn load_sparse_checkout_with_warnings(
385 git_dir: &std::path::Path,
386 cone_config: bool,
387 warnings: &mut Vec<String>,
388) -> (bool, Option<ConePatterns>, NonConePatterns) {
389 let path = git_dir.join("info").join("sparse-checkout");
390 let Ok(content) = std::fs::read_to_string(&path) else {
391 return (false, None, NonConePatterns { lines: Vec::new() });
392 };
393 let non_cone = NonConePatterns::parse(&content);
394 if !cone_config {
395 return (false, None, non_cone);
396 }
397 match ConePatterns::try_parse_with_warnings(&content, warnings) {
398 Some(cone) => (true, Some(cone), non_cone),
399 None => (false, None, non_cone),
400 }
401}
402
403#[must_use]
405pub fn path_in_sparse_checkout(
406 path: &str,
407 cone_config: bool,
408 cone: Option<&ConePatterns>,
409 non_cone: &NonConePatterns,
410 work_tree: Option<&std::path::Path>,
411) -> bool {
412 if cone_config {
413 if let Some(c) = cone {
414 return c.path_included(path);
415 }
416 }
417 crate::ignore::path_in_sparse_checkout(path, non_cone.lines(), work_tree)
418}
419
420pub fn apply_sparse_checkout_skip_worktree(
433 git_dir: &std::path::Path,
434 work_tree: Option<&std::path::Path>,
435 index: &mut crate::index::Index,
436 skip_sparse_checkout: bool,
437) {
438 if skip_sparse_checkout {
439 return;
440 }
441
442 let config = crate::config::ConfigSet::load(Some(git_dir), true)
443 .unwrap_or_else(|_| crate::config::ConfigSet::new());
444 let sparse_enabled = config
445 .get("core.sparsecheckout")
446 .map(|v| v.eq_ignore_ascii_case("true"))
447 .unwrap_or(false);
448
449 if !sparse_enabled {
450 return;
451 }
452
453 let cone_config = config
454 .get("core.sparsecheckoutcone")
455 .map(|v| v.eq_ignore_ascii_case("true"))
456 .unwrap_or(true);
457
458 let mut warnings = Vec::new();
459 let (_cone_ok, _cone_loaded, non_cone) =
460 load_sparse_checkout_with_warnings(git_dir, cone_config, &mut warnings);
461 for line in warnings {
462 eprintln!("{line}");
463 }
464
465 let sparse_path = git_dir.join("info").join("sparse-checkout");
466 let file_content = std::fs::read_to_string(&sparse_path).unwrap_or_default();
467 let sparse_lines = parse_sparse_checkout_file(&file_content);
468
469 let cone_struct = if cone_config {
472 ConePatterns::try_parse(&file_content)
473 } else {
474 None
475 };
476 let effective_cone = cone_config && cone_struct.is_some();
477
478 let sparse_file_exists = sparse_path.is_file();
482 let exclude_all = sparse_file_exists && sparse_lines.is_empty();
483
484 let mut any_skip = false;
485 for entry in &mut index.entries {
486 if entry.stage() != 0 {
487 continue;
488 }
489 let path_str = String::from_utf8_lossy(&entry.path);
490 let included = if exclude_all {
491 false
492 } else if effective_cone {
493 path_in_sparse_checkout(
494 path_str.as_ref(),
495 true,
496 cone_struct.as_ref(),
497 &non_cone,
498 work_tree,
499 )
500 } else {
501 crate::ignore::path_in_sparse_checkout(path_str.as_ref(), non_cone.lines(), work_tree)
502 };
503 entry.set_skip_worktree(!included);
504 if !included {
505 any_skip = true;
506 }
507 }
508
509 if any_skip && index.version < 3 {
510 index.version = 3;
511 }
512}
513
514fn max_common_dir_prefix(path1: &str, path2: &str) -> usize {
516 let b1 = path1.as_bytes();
517 let b2 = path2.as_bytes();
518 let mut common_prefix = 0usize;
519 let mut i = 0usize;
520 while i < b1.len() && i < b2.len() {
521 if b1[i] != b2[i] {
522 break;
523 }
524 if b1[i] == b'/' {
525 common_prefix = i + 1;
526 }
527 i += 1;
528 }
529 common_prefix
530}
531
532struct PathFoundData {
533 dir: String,
535}
536
537fn path_found(path: &str, data: &mut PathFoundData) -> bool {
539 let pb = path.as_bytes();
540 let db = data.dir.as_bytes();
541 if !db.is_empty() && pb.len() >= db.len() && pb[..db.len()] == *db {
542 return false;
543 }
544
545 if std::fs::symlink_metadata(std::path::Path::new(path)).is_ok() {
546 return true;
547 }
548
549 let common_prefix = max_common_dir_prefix(path, &data.dir);
550 data.dir.truncate(common_prefix);
551
552 loop {
553 let rest = &path[data.dir.len()..];
554 if let Some(rel_slash) = rest.find('/') {
555 data.dir.push_str(&rest[..=rel_slash]);
556 if std::fs::symlink_metadata(std::path::Path::new(&data.dir)).is_err() {
557 return false;
558 }
559 } else {
560 data.dir.push_str(rest);
561 data.dir.push('/');
562 break;
563 }
564 }
565 false
566}
567
568pub fn clear_skip_worktree_from_present_files(
574 git_dir: &std::path::Path,
575 work_tree: &std::path::Path,
576 index: &mut crate::index::Index,
577) {
578 let config = crate::config::ConfigSet::load(Some(git_dir), true)
579 .unwrap_or_else(|_| crate::config::ConfigSet::new());
580 let sparse_enabled = config
581 .get("core.sparsecheckout")
582 .map(|v| v.eq_ignore_ascii_case("true"))
583 .unwrap_or(false);
584 if !sparse_enabled {
585 return;
586 }
587 if config
588 .get_bool("sparse.expectfilesoutsideofpatterns")
589 .and_then(|r| r.ok())
590 .unwrap_or(false)
591 {
592 return;
593 }
594
595 let mut found = PathFoundData { dir: String::new() };
596 for entry in &mut index.entries {
597 if entry.stage() != 0 || !entry.skip_worktree() {
598 continue;
599 }
600 if entry.assume_unchanged() {
603 continue;
604 }
605 let rel = String::from_utf8_lossy(&entry.path);
606 let abs = work_tree.join(rel.as_ref());
607 let abs_str = abs.to_string_lossy().into_owned();
608 if path_found(&abs_str, &mut found) {
609 entry.set_skip_worktree(false);
610 }
611 }
612}
613
614#[derive(Debug, Clone, Default)]
616pub struct ConeWorkspace {
617 pub recursive_slash: BTreeSet<String>,
618 pub parent_slash: BTreeSet<String>,
619}
620
621impl ConeWorkspace {
622 #[must_use]
624 pub fn from_cone_patterns(cp: &ConePatterns) -> Self {
625 Self {
626 recursive_slash: cp.recursive_slash.clone(),
627 parent_slash: cp.parent_slash.clone(),
628 }
629 }
630
631 #[must_use]
633 pub fn from_directory_list(dirs: &[String]) -> Self {
634 let mut pruned: Vec<String> = dirs
635 .iter()
636 .map(|s| s.trim_start_matches('/').trim_end_matches('/').to_string())
637 .filter(|s| !s.is_empty())
638 .collect();
639 pruned.sort();
640 let mut kept: Vec<String> = Vec::new();
641 for d in pruned {
642 if kept
643 .iter()
644 .any(|p| d.starts_with(p) && d.as_bytes().get(p.len()) == Some(&b'/'))
645 {
646 continue;
647 }
648 kept.retain(|k| !(k.starts_with(&d) && k.as_bytes().get(d.len()) == Some(&b'/')));
649 kept.push(d);
650 }
651 let mut ws = ConeWorkspace::default();
652 for d in kept {
653 ws.insert_directory(&d);
654 }
655 ws
656 }
657
658 pub fn insert_directory(&mut self, rel: &str) {
660 let rel = rel.trim_start_matches('/');
661 let rel = rel.trim_end_matches('/');
662 if rel.is_empty() {
663 return;
664 }
665 let key = format!("/{rel}");
666 if self.parent_slash.contains(&key) {
667 return;
668 }
669 self.recursive_slash.insert(key.clone());
670 let parts: Vec<&str> = rel.split('/').collect();
671 for i in 1..parts.len() {
672 let prefix = parts[..i].join("/");
673 self.parent_slash.insert(format!("/{prefix}"));
674 }
675 }
676
677 fn recursive_contains_parent(path_slash: &str, recursive: &BTreeSet<String>) -> bool {
678 let mut buf = String::from(path_slash);
679 let mut slash_pos = buf.rfind('/');
680 while let Some(pos) = slash_pos {
681 if pos == 0 {
682 break;
683 }
684 buf.truncate(pos);
685 if recursive.contains(&buf) {
686 return true;
687 }
688 slash_pos = buf.rfind('/');
689 }
690 false
691 }
692
693 #[must_use]
695 pub fn to_sparse_checkout_file(&self) -> String {
696 let mut parent_only: Vec<&String> = self
697 .parent_slash
698 .iter()
699 .filter(|p| {
700 !self.recursive_slash.contains(*p)
701 && !Self::recursive_contains_parent(p, &self.recursive_slash)
702 })
703 .collect();
704 parent_only.sort();
705
706 let mut out = String::new();
707 out.push_str("/*\n!/*/\n");
708
709 for p in parent_only {
710 let esc = escape_cone_path_component(p);
711 out.push_str(&esc);
712 out.push_str("/\n!");
713 out.push_str(&esc);
714 out.push_str("/*/\n");
715 }
716
717 let mut rec_only: Vec<&String> = self
718 .recursive_slash
719 .iter()
720 .filter(|p| !Self::recursive_contains_parent(p, &self.recursive_slash))
721 .collect();
722 rec_only.sort();
723
724 for p in rec_only {
725 let esc = escape_cone_path_component(p);
726 out.push_str(&esc);
727 out.push_str("/\n");
728 }
729 out
730 }
731
732 #[must_use]
734 pub fn list_cone_directories(&self) -> Vec<String> {
735 let mut v: Vec<String> = self
736 .recursive_slash
737 .iter()
738 .map(|s| s.trim_start_matches('/').to_string())
739 .collect();
740 v.sort();
741 v
742 }
743}
744
745fn escape_cone_path_component(path_with_leading_slash: &str) -> String {
746 let mut out = String::new();
747 for ch in path_with_leading_slash.chars() {
748 if matches!(ch, '*' | '?' | '[' | '\\') {
749 out.push('\\');
750 }
751 out.push(ch);
752 }
753 out
754}
755
756pub fn parse_sparse_checkout_file(content: &str) -> Vec<String> {
758 content
759 .lines()
760 .map(|l| l.trim())
761 .filter(|l| !l.is_empty() && !l.starts_with('#'))
762 .map(String::from)
763 .collect()
764}
765
766pub fn sparse_checkout_lines_look_like_expanded_cone(lines: &[String]) -> bool {
769 lines.len() >= 2 && lines[0] == "/*" && lines[1] == "!/*/"
770}
771
772fn parse_expanded_cone_parent_recursive(lines: &[String]) -> Option<(Vec<String>, Vec<String>)> {
775 if !sparse_checkout_lines_look_like_expanded_cone(lines) {
776 return None;
777 }
778 let mut parents = Vec::new();
779 let mut recursive = Vec::new();
780 let mut i = 2usize;
781 while i + 1 < lines.len() {
782 let a = &lines[i];
783 let b = &lines[i + 1];
784 if !a.starts_with('/') || !a.ends_with('/') || !b.starts_with('!') {
785 break;
786 }
787 let inner_a = a.trim_start_matches('/').trim_end_matches('/');
788 let expected_neg = format!("!/{inner_a}/*/");
789 if b != &expected_neg {
790 break;
791 }
792 parents.push(inner_a.to_string());
793 i += 2;
794 }
795 while i < lines.len() {
796 let line = &lines[i];
797 if line.starts_with('!') {
798 return None;
799 }
800 if !line.starts_with('/') || !line.ends_with('/') {
801 return None;
802 }
803 let body = line.trim_start_matches('/').trim_end_matches('/');
804 if body.is_empty() {
805 return None;
806 }
807 recursive.push(body.to_string());
808 i += 1;
809 }
810 Some((parents, recursive))
811}
812
813fn path_in_expanded_cone(path: &str, lines: &[String]) -> bool {
814 let Some((parents, recursive)) = parse_expanded_cone_parent_recursive(lines) else {
815 return false;
816 };
817 let raw = path.trim_start_matches('/');
818 let is_directory_path = raw.ends_with('/');
819 let path = raw.trim_end_matches('/');
820
821 if !path.contains('/') {
822 if !is_directory_path {
826 return true;
827 }
828 if parents.is_empty() && recursive.is_empty() {
829 return false;
830 }
831 return parents.iter().any(|p| p == path)
832 || recursive
833 .iter()
834 .any(|r| r == path || r.starts_with(&format!("{path}/")));
835 }
836
837 for r in &recursive {
838 if path == *r || path.starts_with(&format!("{r}/")) {
839 return true;
840 }
841 }
842
843 for p in &parents {
844 let p_slash = format!("{}/", p);
845 if path == *p {
846 return true;
847 }
848 if !path.starts_with(&p_slash) {
849 continue;
850 }
851 let rest = &path[p_slash.len()..];
852 let Some(slash_pos) = rest.find('/') else {
853 let combined = format!("{}/{}", p, rest);
856 return recursive
857 .iter()
858 .any(|r| r == &combined || r.starts_with(&format!("{combined}/")));
859 };
860 let first = &rest[..slash_pos];
861 let combined = format!("{}/{}", p, first);
862 for r in &recursive {
863 let under_r = path == *r || path.starts_with(&format!("{r}/"));
864 let r_covers = r == &combined || r.starts_with(&format!("{combined}/"));
865 if r_covers && under_r {
866 return true;
867 }
868 }
869 }
870
871 false
872}
873
874#[must_use]
880pub fn effective_cone_mode_for_sparse_file(cone_config: bool, lines: &[String]) -> bool {
881 cone_config && sparse_checkout_lines_look_like_expanded_cone(lines)
882}
883
884pub fn build_expanded_cone_sparse_checkout_lines(dirs: &[String]) -> Vec<String> {
890 let mut recursive: BTreeSet<String> = BTreeSet::new();
891 for d in dirs {
892 let t = d.trim().trim_start_matches('/').trim_end_matches('/');
893 if t.is_empty() {
894 continue;
895 }
896 recursive.insert(format!("/{t}"));
897 }
898
899 let mut parents: BTreeSet<String> = BTreeSet::new();
900 for r in &recursive {
901 let mut cur = r.clone();
902 loop {
903 let Some(slash) = cur.rfind('/') else {
904 break;
905 };
906 if slash == 0 {
907 break;
908 }
909 cur.truncate(slash);
910 parents.insert(cur.clone());
911 }
912 }
913
914 let mut out = vec!["/*".to_owned(), "!/*/".to_owned()];
915
916 for p in parents.iter() {
917 if recursive.contains(p) {
918 continue;
919 }
920 if recursive_set_has_strict_ancestor(&recursive, p) {
921 continue;
922 }
923 let esc = escape_cone_pattern_path(p);
924 out.push(format!("{esc}/"));
925 out.push(format!("!{esc}/*/"));
926 }
927
928 for r in recursive.iter() {
929 if recursive_set_has_strict_ancestor(&recursive, r) {
930 continue;
931 }
932 let esc = escape_cone_pattern_path(r);
933 out.push(format!("{esc}/"));
934 }
935
936 out
937}
938
939fn escape_cone_pattern_path(path_with_leading_slash: &str) -> String {
940 let mut out = String::with_capacity(path_with_leading_slash.len() + 8);
943 for ch in path_with_leading_slash.chars() {
944 match ch {
945 '\\' | '[' | '*' | '?' | '#' => {
946 out.push('\\');
947 out.push(ch);
948 }
949 _ => out.push(ch),
950 }
951 }
952 out
953}
954
955fn recursive_set_has_strict_ancestor(recursive: &BTreeSet<String>, path: &str) -> bool {
956 let mut cur = path.to_string();
957 loop {
958 let Some(slash) = cur.rfind('/') else {
959 break;
960 };
961 if slash == 0 {
962 break;
963 }
964 cur.truncate(slash);
965 if recursive.contains(&cur) {
966 return true;
967 }
968 }
969 false
970}
971
972pub fn parse_expanded_cone_recursive_dirs(lines: &[String]) -> Vec<String> {
975 if !sparse_checkout_lines_look_like_expanded_cone(lines) {
976 return Vec::new();
977 }
978 let mut i = 2usize;
979 let mut out = Vec::new();
980 while i < lines.len() {
981 let line = &lines[i];
982 if line.starts_with('!') {
983 i += 1;
984 continue;
985 }
986 if !line.ends_with('/') || !line.starts_with('/') {
987 i += 1;
988 continue;
989 }
990 let trimmed = line.trim_end_matches('/');
991 let body = trimmed.trim_start_matches('/');
992 let esc = escape_cone_pattern_path(trimmed);
993 let expected_neg = format!("!{esc}/*/");
994 if i + 1 < lines.len() && lines[i + 1] == expected_neg {
995 i += 2;
996 continue;
997 }
998 out.push(body.to_owned());
999 i += 1;
1000 }
1001 out
1002}
1003
1004#[must_use]
1013pub fn cone_directory_inputs_for_add(content: &str) -> Vec<String> {
1014 let lines: Vec<String> = parse_sparse_checkout_file(content);
1015 if sparse_checkout_lines_look_like_expanded_cone(&lines) {
1016 let recursive = parse_expanded_cone_recursive_dirs(&lines);
1017 if !recursive.is_empty() {
1018 return recursive;
1019 }
1020 if lines.len() == 2 {
1021 return Vec::new();
1022 }
1023 return lines[2..]
1027 .iter()
1028 .map(|s| {
1029 s.trim()
1030 .trim_start_matches('/')
1031 .trim_end_matches('/')
1032 .to_string()
1033 })
1034 .filter(|s| !s.is_empty())
1035 .collect();
1036 }
1037 if let Some(cp) = ConePatterns::try_parse(content) {
1038 return ConeWorkspace::from_cone_patterns(&cp).list_cone_directories();
1039 }
1040 lines
1041 .iter()
1042 .map(|s| {
1043 s.trim()
1044 .trim_start_matches('/')
1045 .trim_end_matches('/')
1046 .to_string()
1047 })
1048 .filter(|s| !s.is_empty())
1049 .collect()
1050}
1051
1052pub fn path_in_sparse_checkout_patterns(path: &str, patterns: &[String], cone_mode: bool) -> bool {
1060 if path.is_empty() || patterns.is_empty() {
1061 return true;
1062 }
1063
1064 if sparse_checkout_lines_look_like_expanded_cone(patterns) {
1067 return path_in_expanded_cone(path, patterns);
1068 }
1069
1070 let use_cone_prefix = cone_mode;
1072
1073 let mut end = path.len();
1074 while end > 0 {
1075 if path_matches_sparse_patterns(&path[..end], patterns, use_cone_prefix) {
1076 return true;
1077 }
1078 let Some(slash) = path[..end].rfind('/') else {
1079 break;
1080 };
1081 end = slash;
1082 }
1083 false
1084}
1085
1086pub fn path_in_cone_mode_sparse_checkout(
1091 path: &str,
1092 patterns: &[String],
1093 cone_enabled: bool,
1094) -> bool {
1095 if !cone_enabled || patterns.is_empty() {
1096 return true;
1097 }
1098 path_in_sparse_checkout_patterns(path, patterns, true)
1099}
1100
1101pub fn path_matches_sparse_patterns(path: &str, patterns: &[String], cone_mode: bool) -> bool {
1104 let expanded_cone = sparse_checkout_lines_look_like_expanded_cone(patterns);
1105 if expanded_cone {
1106 return path_in_expanded_cone(path, patterns);
1107 }
1108 let raw_cone_prefix = cone_mode && !expanded_cone;
1111
1112 if raw_cone_prefix {
1113 if !path.contains('/') {
1114 return true;
1115 }
1116
1117 for pattern in patterns {
1118 let prefix = pattern.trim_end_matches('/');
1119 if path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/') {
1120 return true;
1121 }
1122 if path == prefix {
1123 return true;
1124 }
1125 }
1126 return false;
1127 }
1128
1129 let mut included = false;
1130 for raw_pattern in patterns {
1131 let pattern = raw_pattern.trim();
1132 if pattern.is_empty() || pattern.starts_with('#') {
1133 continue;
1134 }
1135
1136 let (negated, core_pattern) = if let Some(rest) = pattern.strip_prefix('!') {
1137 (true, rest)
1138 } else {
1139 (false, pattern)
1140 };
1141 if core_pattern.is_empty() || core_pattern == "/" {
1142 continue;
1143 }
1144
1145 let matches = if let Some(prefix_with_slash) = core_pattern.strip_suffix('/') {
1146 let inner = prefix_with_slash.trim_start_matches('/');
1148 if inner.is_empty() {
1149 false
1150 } else if inner == "*" {
1151 let trimmed = path.trim_end_matches('/');
1154 trimmed.contains('/')
1155 } else if inner.contains('*') || inner.contains('?') || inner.contains('[') {
1156 let pat = format!("{prefix_with_slash}/");
1158 let text = format!("/{path}/");
1159 wildmatch(pat.as_bytes(), text.as_bytes(), WM_PATHNAME)
1160 } else {
1161 path == inner || path.starts_with(&format!("{inner}/"))
1162 }
1163 } else if core_pattern.starts_with('/') {
1164 let text = format!("/{}", path.trim_start_matches('/'));
1166 wildmatch(core_pattern.as_bytes(), text.as_bytes(), WM_PATHNAME)
1167 } else {
1168 wildmatch(core_pattern.as_bytes(), path.as_bytes(), WM_PATHNAME)
1169 };
1170
1171 if matches {
1172 included = !negated;
1173 }
1174 }
1175
1176 included
1177}
1178
1179#[cfg(test)]
1180mod path_in_expanded_cone_tests {
1181 use super::path_in_sparse_checkout_patterns;
1182
1183 #[test]
1184 fn root_only_cone_includes_files_not_top_level_dirs() {
1185 let lines = vec!["/*".to_string(), "!/*/".to_string()];
1186 assert!(path_in_sparse_checkout_patterns("file.1.txt", &lines, true));
1187 assert!(!path_in_sparse_checkout_patterns("a/", &lines, true));
1188 assert!(!path_in_sparse_checkout_patterns("d/", &lines, true));
1189 }
1190
1191 #[test]
1192 fn expanded_cone_with_d_includes_d_tree_not_sibling_a() {
1193 let lines = vec!["/*".to_string(), "!/*/".to_string(), "/d/".to_string()];
1194 assert!(path_in_sparse_checkout_patterns("file.1.txt", &lines, true));
1195 assert!(path_in_sparse_checkout_patterns("d/", &lines, true));
1196 assert!(!path_in_sparse_checkout_patterns("a/", &lines, true));
1197 assert!(path_in_sparse_checkout_patterns(
1198 "d/e/file.1.txt",
1199 &lines,
1200 true
1201 ));
1202 }
1203}
1204
1205#[cfg(test)]
1206mod cone_directory_inputs_for_add_tests {
1207 use super::cone_directory_inputs_for_add;
1208
1209 #[test]
1210 fn expanded_header_with_non_cone_body_preserves_literal_dir() {
1211 let content = "/*\n!/*/\ndir\n";
1212 assert_eq!(
1213 cone_directory_inputs_for_add(content),
1214 vec!["dir".to_string()]
1215 );
1216 }
1217
1218 #[test]
1219 fn pure_expanded_cone_uses_recursive_dirs_only() {
1220 let content = "/*\n!/*/\n/sub/\n";
1221 assert_eq!(
1222 cone_directory_inputs_for_add(content),
1223 vec!["sub".to_string()]
1224 );
1225 }
1226}