1use std::path::{Component, Path, PathBuf};
32
33use walkdir::WalkDir;
34
35use crate::error::PackError;
36use crate::manifest::{
37 CacheRecord, KeptOverSecret, SkipRecord, SymlinkRecord, WorktreeOrigin, WorktreeRecord,
38};
39use crate::rules::{FileVerdict, PackRules};
40
41const NOISE_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum EntryKind {
47 File,
49 Dir,
51 Symlink,
53}
54
55#[derive(Debug, Clone)]
57pub struct Entry {
58 pub rel: String,
60 pub abs: PathBuf,
62 pub kind: EntryKind,
64 pub size: u64,
66}
67
68#[derive(Debug, Default)]
70pub struct Scan {
71 pub entries: Vec<Entry>,
73 pub skipped_cache: Vec<CacheRecord>,
75 pub skipped_secret: Vec<SkipRecord>,
77 pub skipped_noise: Vec<SkipRecord>,
79 pub symlinks: Vec<SymlinkRecord>,
82 pub no_link_report_applied: Vec<String>,
88 pub kept_over_secret: Vec<KeptOverSecret>,
90 pub worktrees: Vec<WorktreeRecord>,
92 pub worktree_of: Option<WorktreeOrigin>,
94}
95
96impl Scan {
97 pub fn total_bytes(&self) -> u64 {
99 self.entries.iter().map(|e| e.size).sum()
100 }
101
102 pub fn file_count(&self) -> u64 {
104 self.entries
105 .iter()
106 .filter(|e| e.kind == EntryKind::File)
107 .count() as u64
108 }
109
110 pub fn symlink_count(&self) -> u64 {
112 self.entries
113 .iter()
114 .filter(|e| e.kind == EntryKind::Symlink)
115 .count() as u64
116 }
117}
118
119pub fn scan(root: &Path) -> Result<Scan, PackError> {
128 scan_with(root, &PackRules::default())
129}
130
131pub fn scan_with(root: &Path, rules: &PackRules) -> Result<Scan, PackError> {
147 if !root.is_dir() {
148 return Err(PackError::NotADirectory(root.to_path_buf()));
149 }
150 let root = &canonicalize_or(root);
155
156 let mut scan = Scan::default();
157
158 let walker = WalkDir::new(root)
159 .follow_links(false)
160 .min_depth(1)
161 .sort_by_file_name()
162 .into_iter();
163
164 let it = walker.filter_entry(|e| {
167 let name = e.file_name().to_string_lossy();
168 if e.file_type().is_symlink() {
170 return true;
171 }
172 if !e.file_type().is_dir() {
173 return true;
174 }
175 let Some(rel) = rel_path(root, e.path()) else {
178 return true;
179 };
180 !rules.is_cache_dir(name.as_ref(), &rel)
181 });
182
183 collect_cache_records(root, rules, &mut scan)?;
186
187 for next in it {
188 let entry = next?;
189 let abs = entry.path().to_path_buf();
190 let Some(rel) = rel_path(root, &abs) else {
191 continue;
192 };
193 let name = entry.file_name().to_string_lossy().to_string();
194
195 if NOISE_FILES.contains(&name.as_str()) {
196 scan.skipped_noise.push(SkipRecord {
197 path: rel,
198 reason: format!("os debris: {name}"),
199 });
200 continue;
201 }
202
203 let file_type = entry.file_type();
204
205 if file_type.is_symlink() {
206 let target = std::fs::read_link(&abs)?;
207 match rules.no_link_report_match(&name, &rel) {
211 Some(glob) => {
212 if !scan.no_link_report_applied.iter().any(|g| g == glob) {
213 scan.no_link_report_applied.push(glob.to_string());
214 }
215 }
216 None => scan.symlinks.push(SymlinkRecord {
217 path: rel.clone(),
218 target: target.to_string_lossy().into_owned(),
219 outside_root: resolves_outside(root, &abs, &target),
220 }),
221 }
222 scan.entries.push(Entry {
223 rel,
224 abs,
225 kind: EntryKind::Symlink,
226 size: 0,
227 });
228 continue;
229 }
230
231 if file_type.is_dir() {
232 scan.entries.push(Entry {
233 rel,
234 abs,
235 kind: EntryKind::Dir,
236 size: 0,
237 });
238 continue;
239 }
240
241 match rules.classify(&name, &rel) {
242 FileVerdict::Secret { pattern } => {
243 scan.skipped_secret.push(SkipRecord {
244 path: rel,
245 reason: format!("secret pattern: {pattern}"),
246 });
247 continue;
248 }
249 FileVerdict::KeptOverSecret {
252 keep_pattern,
253 secret_pattern,
254 } => scan.kept_over_secret.push(KeptOverSecret {
255 path: rel.clone(),
256 keep_pattern,
257 secret_pattern,
258 }),
259 FileVerdict::Ordinary => {}
260 }
261
262 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
263 scan.entries.push(Entry {
264 rel,
265 abs,
266 kind: EntryKind::File,
267 size,
268 });
269 }
270
271 scan.worktrees = discover_worktrees(root)?;
272 scan.worktree_of = discover_worktree_origin(root);
273
274 Ok(scan)
275}
276
277fn collect_cache_records(root: &Path, rules: &PackRules, scan: &mut Scan) -> Result<(), PackError> {
284 let walker = WalkDir::new(root)
285 .follow_links(false)
286 .min_depth(1)
287 .sort_by_file_name()
288 .into_iter();
289
290 let mut it = walker.filter_entry(|e| {
291 if e.file_type().is_symlink() {
292 return false;
293 }
294 if !e.file_type().is_dir() {
295 return false;
296 }
297 true
298 });
299
300 while let Some(next) = it.next() {
301 let entry = next?;
302 let name = entry.file_name().to_string_lossy().to_string();
303 let Some(rel) = rel_path(root, entry.path()) else {
304 continue;
305 };
306 if !rules.is_cache_dir(&name, &rel) {
307 continue;
308 }
309 let (file_count, total_bytes, secrets) = measure_cache(entry.path(), root, rules);
310 scan.skipped_cache.push(CacheRecord {
311 path: rel,
312 reason: format!("cache directory: {name}"),
313 file_count,
314 total_bytes,
315 secrets,
316 });
317 it.skip_current_dir();
318 }
319
320 Ok(())
321}
322
323fn measure_cache(dir: &Path, root: &Path, rules: &PackRules) -> (u64, u64, Vec<SkipRecord>) {
343 let mut file_count = 0;
344 let mut total_bytes = 0;
345 let mut secrets = Vec::new();
346
347 for entry in WalkDir::new(dir)
348 .follow_links(false)
349 .sort_by_file_name()
350 .into_iter()
351 .filter_map(Result::ok)
352 .filter(|e| e.file_type().is_file())
353 {
354 if let Ok(meta) = entry.metadata() {
355 file_count += 1;
356 total_bytes += meta.len();
357 }
358
359 let name = entry.file_name().to_string_lossy().to_string();
360 let Some(rel) = rel_path(root, entry.path()) else {
361 continue;
362 };
363 if let FileVerdict::Secret { pattern } = rules.classify(&name, &rel) {
367 secrets.push(SkipRecord {
368 path: rel,
369 reason: format!("secret pattern: {pattern}"),
370 });
371 }
372 }
373
374 (file_count, total_bytes, secrets)
375}
376
377fn rel_path(root: &Path, abs: &Path) -> Option<String> {
379 let rel = abs.strip_prefix(root).ok()?;
380 let s = rel
381 .components()
382 .map(|c| c.as_os_str().to_string_lossy())
383 .collect::<Vec<_>>()
384 .join("/");
385 if s.is_empty() { None } else { Some(s) }
386}
387
388fn resolves_outside(root: &Path, link_path: &Path, target: &Path) -> bool {
396 let joined = if target.is_absolute() {
397 target.to_path_buf()
398 } else {
399 match link_path.parent() {
400 Some(parent) => parent.join(target),
401 None => return true,
402 }
403 };
404 !canonicalize_or(&joined).starts_with(canonicalize_or(root))
405}
406
407pub(crate) fn canonicalize_or(path: &Path) -> PathBuf {
410 std::fs::canonicalize(path).unwrap_or_else(|_| normalize(path))
411}
412
413pub(crate) fn normalize(path: &Path) -> PathBuf {
415 let mut out = PathBuf::new();
416 for component in path.components() {
417 match component {
418 Component::ParentDir => {
419 out.pop();
420 }
421 Component::CurDir => {}
422 other => out.push(other.as_os_str()),
423 }
424 }
425 out
426}
427
428fn discover_worktrees(root: &Path) -> Result<Vec<WorktreeRecord>, PackError> {
437 let admin = root.join(".git").join("worktrees");
438 if !admin.is_dir() {
439 return Ok(Vec::new());
440 }
441
442 let mut records = Vec::new();
443 let mut dirs: Vec<PathBuf> = std::fs::read_dir(&admin)?
444 .filter_map(|e| e.ok())
445 .map(|e| e.path())
446 .filter(|p| p.is_dir())
447 .collect();
448 dirs.sort();
449
450 for dir in dirs {
451 let Some(name) = dir.file_name().map(|n| n.to_string_lossy().into_owned()) else {
452 continue;
453 };
454 let gitdir_file = dir.join("gitdir");
455 let Ok(contents) = std::fs::read_to_string(&gitdir_file) else {
456 continue;
457 };
458 let dot_git = PathBuf::from(contents.trim());
461 let Some(worktree_root) = dot_git.parent() else {
462 continue;
463 };
464 let resolved = canonicalize_or(worktree_root);
467 let rel = rel_path(root, &resolved);
468 records.push(WorktreeRecord {
469 name,
470 included: rel.is_some(),
471 path: rel,
472 source_path: resolved.to_string_lossy().into_owned(),
476 });
477 }
478
479 Ok(records)
480}
481
482fn discover_worktree_origin(root: &Path) -> Option<WorktreeOrigin> {
493 let dot_git = root.join(".git");
494 if !dot_git.is_file() {
495 return None;
496 }
497 let contents = std::fs::read_to_string(&dot_git).ok()?;
498 let admin = PathBuf::from(contents.trim().strip_prefix("gitdir:")?.trim());
499
500 let name = admin.file_name()?.to_string_lossy().into_owned();
502 let worktrees_dir = admin.parent()?;
503 if worktrees_dir.file_name()? != "worktrees" {
504 return None;
505 }
506 let git_dir = worktrees_dir.parent()?;
507 if git_dir.file_name()? != ".git" {
508 return None;
509 }
510 let parent_root = canonicalize_or(git_dir.parent()?);
511
512 Some(WorktreeOrigin {
513 name,
514 admin_path: admin.to_string_lossy().into_owned(),
515 parent_root: parent_root.to_string_lossy().into_owned(),
518 })
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use std::fs;
525 use tempfile::TempDir;
526
527 fn touch(path: &Path) {
528 if let Some(parent) = path.parent() {
529 fs::create_dir_all(parent).expect("mkdir should succeed in test");
530 }
531 fs::write(path, b"x").expect("write should succeed in test");
532 }
533
534 fn rels(scan: &Scan) -> Vec<String> {
535 scan.entries.iter().map(|e| e.rel.clone()).collect()
536 }
537
538 #[test]
544 fn test_scan_partitions_tree() {
545 let dir = TempDir::new().expect("tempdir");
546 let root = dir.path();
547
548 touch(&root.join("src/main.rs"));
549 touch(&root.join(".git/HEAD"));
550 touch(&root.join("workspace/journal.md"));
551 touch(&root.join("workspace/.journal.db"));
552 touch(&root.join(".mcp.json"));
553 touch(&root.join("target/debug/binary"));
554 touch(&root.join("crates/inner/target/x.rlib"));
555 touch(&root.join(".env"));
556 touch(&root.join(".env.example"));
557 touch(&root.join("key.pem"));
558
559 let scan = scan(root).expect("scan should succeed");
560 let packed = rels(&scan);
561
562 assert!(packed.contains(&"src/main.rs".to_string()));
563 assert!(
564 packed.contains(&".git/HEAD".to_string()),
565 "`.git` must travel"
566 );
567 assert!(packed.contains(&"workspace/journal.md".to_string()));
568 assert!(
569 packed.contains(&"workspace/.journal.db".to_string()),
570 "journal database is exactly the local state a pack exists to carry"
571 );
572 assert!(packed.contains(&".mcp.json".to_string()));
573 assert!(packed.contains(&".env.example".to_string()));
574
575 assert!(
576 !packed.iter().any(|p| p.starts_with("target/")),
577 "cache tree must not be packed"
578 );
579 assert!(
580 !packed.iter().any(|p| p.contains("/target/")),
581 "nested cache tree must not be packed"
582 );
583 assert!(!packed.contains(&".env".to_string()));
584 assert!(!packed.contains(&"key.pem".to_string()));
585
586 let secrets: Vec<&str> = scan
587 .skipped_secret
588 .iter()
589 .map(|s| s.path.as_str())
590 .collect();
591 assert!(secrets.contains(&".env"));
592 assert!(secrets.contains(&"key.pem"));
593
594 let caches: Vec<&str> = scan.skipped_cache.iter().map(|s| s.path.as_str()).collect();
595 assert!(caches.contains(&"target"));
596 assert!(caches.contains(&"crates/inner/target"));
597 }
598
599 #[test]
602 fn test_cache_record_measures_what_it_dropped() {
603 use crate::rules::RuleOverrides;
604 let dir = TempDir::new().expect("tempdir");
605 let root = dir.path();
606
607 fs::create_dir_all(root.join("dist/nested")).expect("mkdir");
608 fs::write(root.join("dist/a.js"), "0123456789").expect("write");
609 fs::write(root.join("dist/nested/b.js"), "01234").expect("write");
610
611 let rules = PackRules::new(&RuleOverrides {
612 cache_dirs: vec!["dist".to_string()],
613 ..RuleOverrides::default()
614 })
615 .expect("compile");
616 let scan = scan_with(root, &rules).expect("scan should succeed");
617
618 assert_eq!(scan.skipped_cache.len(), 1);
619 let dropped = &scan.skipped_cache[0];
620 assert_eq!(dropped.path, "dist");
621 assert_eq!(
622 dropped.file_count, 2,
623 "counts the whole subtree, not depth 1"
624 );
625 assert_eq!(dropped.total_bytes, 15);
626 assert!(dropped.secrets.is_empty());
627 }
628
629 #[test]
633 fn test_secrets_inside_a_dropped_cache_are_named() {
634 let dir = TempDir::new().expect("tempdir");
635 let root = dir.path();
636
637 fs::create_dir_all(root.join("node_modules/pkg")).expect("mkdir");
638 touch(&root.join("node_modules/.npmrc"));
639 touch(&root.join("node_modules/pkg/index.js"));
640 touch(&root.join("node_modules/.env.example"));
641
642 let scan = scan(root).expect("scan should succeed");
643
644 assert_eq!(scan.skipped_cache.len(), 1);
645 let dropped = &scan.skipped_cache[0];
646 assert_eq!(dropped.path, "node_modules");
647
648 let named: Vec<&str> = dropped.secrets.iter().map(|s| s.path.as_str()).collect();
649 assert_eq!(
650 named,
651 vec!["node_modules/.npmrc"],
652 "a file `keep` already calls safe is not re-flagged as a credential"
653 );
654 assert!(
655 dropped.secrets[0].reason.contains(".npmrc"),
656 "the rule that flagged it has to be visible, got {:?}",
657 dropped.secrets[0].reason
658 );
659
660 assert!(rels(&scan).iter().all(|p| !p.starts_with("node_modules/")));
662 assert!(scan.skipped_secret.is_empty());
663 }
664
665 #[test]
668 fn test_path_scoped_keep_end_to_end() {
669 use crate::rules::RuleOverrides;
670 let dir = TempDir::new().expect("tempdir");
671 let root = dir.path();
672
673 fs::create_dir_all(root.join("docs/samples")).expect("mkdir");
674 fs::create_dir_all(root.join("deploy")).expect("mkdir");
675 touch(&root.join("docs/samples/demo.pem"));
676 touch(&root.join("deploy/server.pem"));
677
678 let rules = PackRules::new(&RuleOverrides {
679 keep: vec!["docs/samples/*.pem".to_string()],
680 ..RuleOverrides::default()
681 })
682 .expect("compile");
683 let scan = scan_with(root, &rules).expect("scan should succeed");
684
685 let packed = rels(&scan);
686 assert!(packed.contains(&"docs/samples/demo.pem".to_string()));
687 assert!(
688 !packed.contains(&"deploy/server.pem".to_string()),
689 "the real key must stay out"
690 );
691
692 assert_eq!(scan.kept_over_secret.len(), 1);
693 assert_eq!(scan.kept_over_secret[0].path, "docs/samples/demo.pem");
694 assert_eq!(scan.skipped_secret.len(), 1);
695 assert_eq!(scan.skipped_secret[0].path, "deploy/server.pem");
696 }
697
698 #[test]
701 fn test_os_debris_is_dropped_but_recorded() {
702 let dir = TempDir::new().expect("tempdir");
703 let root = dir.path();
704
705 fs::create_dir_all(root.join("sub")).expect("mkdir");
706 touch(&root.join(".DS_Store"));
707 touch(&root.join("sub/.DS_Store"));
708 touch(&root.join("keep.txt"));
709
710 let scan = scan(root).expect("scan should succeed");
711
712 let packed = rels(&scan);
713 assert!(packed.contains(&"keep.txt".to_string()));
714 assert!(
715 !packed.iter().any(|p| p.ends_with(".DS_Store")),
716 "debris must not be packed"
717 );
718
719 let noise: Vec<&str> = scan.skipped_noise.iter().map(|s| s.path.as_str()).collect();
720 assert_eq!(noise, vec![".DS_Store", "sub/.DS_Store"]);
721 assert!(scan.skipped_noise[0].reason.contains(".DS_Store"));
722 }
723
724 #[cfg(unix)]
726 #[test]
727 fn test_scan_records_symlinks_individually() {
728 let dir = TempDir::new().expect("tempdir");
729 let root = dir.path();
730 let outside = TempDir::new().expect("tempdir");
731
732 touch(&root.join("real.txt"));
733 std::os::unix::fs::symlink(root.join("real.txt"), root.join("inside-link"))
734 .expect("symlink");
735 std::os::unix::fs::symlink(outside.path().join("far.txt"), root.join("outside-link"))
736 .expect("symlink");
737
738 let scan = scan(root).expect("scan should succeed");
739
740 assert_eq!(scan.symlinks.len(), 2);
741 let inside = scan
742 .symlinks
743 .iter()
744 .find(|s| s.path == "inside-link")
745 .expect("inside link recorded");
746 let outside_rec = scan
747 .symlinks
748 .iter()
749 .find(|s| s.path == "outside-link")
750 .expect("outside link recorded");
751 assert!(!inside.outside_root);
752 assert!(outside_rec.outside_root);
753
754 assert!(rels(&scan).contains(&"outside-link".to_string()));
755 }
756
757 #[cfg(unix)]
760 fn link_farm(root: &Path, shared: &Path) -> (String, String) {
761 let first = shared.join("group/alpha");
762 let second = shared.join("group/beta");
763 fs::create_dir_all(&first).expect("mkdir");
764 fs::create_dir_all(&second).expect("mkdir");
765 touch(&first.join("a.md"));
766 touch(&second.join("b.md"));
767
768 fs::create_dir_all(root.join("links")).expect("mkdir");
769 std::os::unix::fs::symlink(first.join("a.md"), root.join("links/a.md")).expect("symlink");
770 std::os::unix::fs::symlink(second.join("b.md"), root.join("links/b.md")).expect("symlink");
771
772 ("links/a.md".to_string(), "links/b.md".to_string())
773 }
774
775 fn rules_with_no_link_report(globs: &[&str]) -> PackRules {
776 use crate::rules::RuleOverrides;
777 PackRules::new(&RuleOverrides {
778 no_link_report: globs.iter().map(|g| (*g).to_string()).collect(),
779 ..RuleOverrides::default()
780 })
781 .expect("globs should compile")
782 }
783
784 #[cfg(unix)]
787 #[test]
788 fn test_declared_path_leaves_the_link_report() {
789 let dir = TempDir::new().expect("tempdir");
790 let shared = TempDir::new().expect("tempdir");
791 let (linked_a, linked_b) = link_farm(dir.path(), shared.path());
792
793 let scan = scan_with(dir.path(), &rules_with_no_link_report(&["links/**"]))
794 .expect("scan should succeed");
795
796 assert!(
797 scan.symlinks.is_empty(),
798 "a declared link must not be reported, got {:?}",
799 scan.symlinks
800 );
801 assert_eq!(
802 scan.no_link_report_applied,
803 vec!["links/**".to_string()],
804 "the rule that suppressed the report has to be visible"
805 );
806 assert!(rels(&scan).contains(&linked_a));
808 assert!(rels(&scan).contains(&linked_b));
809 }
810
811 #[cfg(unix)]
814 #[test]
815 fn test_every_link_is_reported_by_default() {
816 let dir = TempDir::new().expect("tempdir");
817 let shared = TempDir::new().expect("tempdir");
818 link_farm(dir.path(), shared.path());
819
820 let scan = scan(dir.path()).expect("scan should succeed");
821
822 assert!(scan.no_link_report_applied.is_empty());
823 assert_eq!(
824 scan.symlinks.len(),
825 2,
826 "undeclared links are reported one by one"
827 );
828 }
829
830 #[cfg(unix)]
833 #[test]
834 fn test_unmatched_rule_is_not_recorded_as_applied() {
835 let dir = TempDir::new().expect("tempdir");
836 let shared = TempDir::new().expect("tempdir");
837 link_farm(dir.path(), shared.path());
838
839 let scan = scan_with(dir.path(), &rules_with_no_link_report(&["vendor/**"]))
840 .expect("scan should succeed");
841
842 assert!(scan.no_link_report_applied.is_empty());
843 assert_eq!(scan.symlinks.len(), 2);
844 }
845
846 #[cfg(unix)]
848 #[test]
849 fn test_applied_rules_are_deduplicated() {
850 let dir = TempDir::new().expect("tempdir");
851 let shared = TempDir::new().expect("tempdir");
852 link_farm(dir.path(), shared.path());
853
854 let scan = scan_with(
855 dir.path(),
856 &rules_with_no_link_report(&["links/a.md", "links/**"]),
857 )
858 .expect("scan should succeed");
859
860 assert!(scan.symlinks.is_empty());
861 assert_eq!(
862 scan.no_link_report_applied,
863 vec!["links/a.md".to_string(), "links/**".to_string()],
864 "both rules fired, each recorded once"
865 );
866 }
867
868 #[test]
871 fn test_keep_over_secret_is_recorded() {
872 use crate::rules::RuleOverrides;
873 let dir = TempDir::new().expect("tempdir");
874 touch(&dir.path().join(".env.example"));
875 touch(&dir.path().join(".env"));
876
877 let scan = scan(dir.path()).expect("scan should succeed");
878
879 assert_eq!(scan.kept_over_secret.len(), 1);
880 let kept = &scan.kept_over_secret[0];
881 assert_eq!(kept.path, ".env.example");
882 assert_eq!(kept.keep_pattern, ".env.example");
883 assert_eq!(kept.secret_pattern, ".env.*");
884 assert!(rels(&scan).contains(&".env.example".to_string()));
885
886 assert_eq!(scan.skipped_secret.len(), 1);
888 assert_eq!(scan.skipped_secret[0].path, ".env");
889
890 let rules = PackRules::new(&RuleOverrides {
893 keep: vec!["*.pem".to_string()],
894 ..RuleOverrides::default()
895 })
896 .expect("glob should compile");
897 touch(&dir.path().join("server.pem"));
898 let scan = scan_with(dir.path(), &rules).expect("scan should succeed");
899
900 assert!(
901 scan.kept_over_secret
902 .iter()
903 .any(|k| k.path == "server.pem" && k.secret_pattern == "*.pem"),
904 "a keep rule outranking a secret rule must never be silent, got {:?}",
905 scan.kept_over_secret
906 );
907 }
908
909 #[test]
911 fn test_scan_without_worktrees() {
912 let dir = TempDir::new().expect("tempdir");
913 touch(&dir.path().join(".git/HEAD"));
914 let scan = scan(dir.path()).expect("scan should succeed");
915 assert!(scan.worktrees.is_empty());
916 }
917
918 #[test]
920 fn test_scan_discovers_inside_worktree() {
921 let dir = TempDir::new().expect("tempdir");
922 let root = dir.path();
923 let wt = root.join(".worktrees/feature");
924 touch(&wt.join("file.txt"));
925 fs::write(wt.join(".git"), "gitdir: /ignored\n").expect("write");
926 let admin = root.join(".git/worktrees/feature");
927 fs::create_dir_all(&admin).expect("mkdir");
928 fs::write(
929 admin.join("gitdir"),
930 format!("{}\n", wt.join(".git").display()),
931 )
932 .expect("write");
933
934 let scan = scan(root).expect("scan should succeed");
935
936 assert_eq!(scan.worktrees.len(), 1);
937 let rec = &scan.worktrees[0];
938 assert_eq!(rec.name, "feature");
939 assert_eq!(rec.path.as_deref(), Some(".worktrees/feature"));
940 assert!(rec.included);
941 assert!(rels(&scan).contains(&".worktrees/feature/file.txt".to_string()));
942 }
943
944 #[test]
946 fn test_scan_reports_outside_worktree_without_including_it() {
947 let dir = TempDir::new().expect("tempdir");
948 let root = dir.path();
949 let elsewhere = TempDir::new().expect("tempdir");
950 let wt = elsewhere.path().join("detached");
951 touch(&wt.join("file.txt"));
952
953 let admin = root.join(".git/worktrees/detached");
954 fs::create_dir_all(&admin).expect("mkdir");
955 fs::write(
956 admin.join("gitdir"),
957 format!("{}\n", wt.join(".git").display()),
958 )
959 .expect("write");
960
961 let scan = scan(root).expect("scan should succeed");
962
963 assert_eq!(scan.worktrees.len(), 1);
964 assert!(!scan.worktrees[0].included);
965 assert!(scan.worktrees[0].path.is_none());
966 assert!(!rels(&scan).iter().any(|p| p.contains("detached/file.txt")));
967 }
968
969 #[test]
973 fn test_scan_records_the_repository_a_worktree_belongs_to() {
974 let dir = TempDir::new().expect("tempdir");
975 let parent = dir.path().join("proj");
976 let admin = parent.join(".git/worktrees/feature");
977 fs::create_dir_all(&admin).expect("mkdir");
978
979 let wt = dir.path().join("proj-feature");
980 touch(&wt.join("work.txt"));
981 fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
982
983 let scan = scan(&wt).expect("scan should succeed");
984
985 let origin = scan
986 .worktree_of
987 .expect("a worktree must know its repository");
988 assert_eq!(origin.name, "feature");
989 assert_eq!(origin.admin_path, admin.display().to_string());
990 assert_eq!(
991 origin.parent_root,
992 fs::canonicalize(&parent)
993 .expect("canonicalize")
994 .display()
995 .to_string()
996 );
997 assert!(scan.worktrees.is_empty());
999 }
1000
1001 #[test]
1003 fn test_scan_records_no_origin_for_an_ordinary_repository() {
1004 let dir = TempDir::new().expect("tempdir");
1005 touch(&dir.path().join(".git/HEAD"));
1006
1007 let scan = scan(dir.path()).expect("scan should succeed");
1008
1009 assert!(scan.worktree_of.is_none());
1010 }
1011
1012 #[test]
1015 fn test_scan_ignores_an_unrecognized_git_file() {
1016 let dir = TempDir::new().expect("tempdir");
1017 fs::write(dir.path().join(".git"), "gitdir: /somewhere/odd\n").expect("write");
1018
1019 let scan = scan(dir.path()).expect("scan should succeed");
1020
1021 assert!(scan.worktree_of.is_none());
1022 }
1023
1024 #[test]
1026 fn test_scan_rejects_non_directory() {
1027 let dir = TempDir::new().expect("tempdir");
1028 let file = dir.path().join("f.txt");
1029 touch(&file);
1030 assert!(matches!(scan(&file), Err(PackError::NotADirectory(_))));
1031 }
1032
1033 #[test]
1039 fn test_resolves_outside_relative_target() {
1040 let root = Path::new("/proj");
1041 assert!(!resolves_outside(
1042 root,
1043 Path::new("/proj/sub/link"),
1044 Path::new("../file.txt")
1045 ));
1046 assert!(resolves_outside(
1047 root,
1048 Path::new("/proj/sub/link"),
1049 Path::new("../../escape.txt")
1050 ));
1051 }
1052}