1use std::path::Path;
8use std::process::Command;
9
10use crate::error::WorktreeError;
11use crate::git;
12use crate::types::{GitCapabilities, GitCryptStatus, WorktreeHandle, WorktreeState};
13
14pub(crate) fn check_branch_not_checked_out(
17 repo: &Path,
18 branch: &str,
19 caps: &GitCapabilities,
20) -> Result<(), WorktreeError> {
21 let worktrees = git::run_worktree_list(repo, caps)?;
22 for wt in &worktrees {
23 if wt.branch == branch {
24 return Err(WorktreeError::BranchAlreadyCheckedOut {
25 branch: branch.to_string(),
26 worktree: wt.path.clone(),
27 });
28 }
29 }
30 Ok(())
31}
32
33pub(crate) fn check_disk_space(target_path: &Path, required_mb: u64) -> Result<(), WorktreeError> {
36 use sysinfo::Disks;
38
39 let check_path = if target_path.exists() {
40 target_path.to_path_buf()
41 } else {
42 target_path
44 .parent()
45 .unwrap_or(Path::new("/"))
46 .to_path_buf()
47 };
48
49 let disks = Disks::new_with_refreshed_list();
50
51 let mut best_match: Option<&sysinfo::Disk> = None;
53 let mut best_len = 0;
54
55 for disk in disks.list() {
56 let mount = disk.mount_point();
57 if check_path.starts_with(mount) {
58 let len = mount.as_os_str().len();
59 if len > best_len {
60 best_len = len;
61 best_match = Some(disk);
62 }
63 }
64 }
65
66 if let Some(disk) = best_match {
67 let available_mb = disk.available_space() / (1024 * 1024);
68 if available_mb < required_mb {
69 return Err(WorktreeError::DiskSpaceLow {
70 available_mb,
71 required_mb,
72 });
73 }
74 }
75 Ok(())
78}
79
80pub(crate) fn check_worktree_count(current: usize, max: usize) -> Result<(), WorktreeError> {
82 if current >= max {
83 return Err(WorktreeError::RateLimitExceeded { current, max });
84 }
85 Ok(())
86}
87
88pub(crate) fn check_path_not_exists(path: &Path) -> Result<(), WorktreeError> {
90 if path.exists() {
91 return Err(WorktreeError::WorktreePathExists(path.to_path_buf()));
92 }
93 Ok(())
94}
95
96pub(crate) fn check_not_nested_worktree(
103 candidate: &Path,
104 repo_root: &Path,
105 existing: &[WorktreeHandle],
106) -> Result<(), WorktreeError> {
107 let canon_candidate = if candidate.exists() {
111 dunce::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf())
112 } else if let Some(parent) = candidate.parent() {
113 let canon_parent = dunce::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
114 if let Some(file_name) = candidate.file_name() {
115 canon_parent.join(file_name)
116 } else {
117 canon_parent
118 }
119 } else {
120 candidate.to_path_buf()
121 };
122
123 let canon_repo = dunce::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
124
125 for wt in existing {
126 let canon_existing = dunce::canonicalize(&wt.path).unwrap_or_else(|_| wt.path.clone());
127
128 if canon_existing == canon_repo {
131 continue;
132 }
133
134 if canon_candidate.starts_with(&canon_existing) {
136 return Err(WorktreeError::NestedWorktree {
137 parent: wt.path.clone(),
138 });
139 }
140 if canon_existing.starts_with(&canon_candidate) {
142 return Err(WorktreeError::NestedWorktree {
143 parent: canon_candidate,
144 });
145 }
146 }
147 Ok(())
148}
149
150pub(crate) fn check_not_network_filesystem(path: &Path) -> Result<(), WorktreeError> {
154 #[cfg(target_os = "macos")]
156 {
157 let path_cstr = std::ffi::CString::new(
158 path.to_str().unwrap_or("/"),
159 )
160 .unwrap_or_else(|_| std::ffi::CString::new("/").unwrap());
161
162 unsafe {
163 let mut stat: libc::statfs = std::mem::zeroed();
164 if libc::statfs(path_cstr.as_ptr(), &mut stat) == 0 {
165 let fstype = std::ffi::CStr::from_ptr(stat.f_fstypename.as_ptr())
166 .to_string_lossy();
167 let network_types = ["nfs", "smbfs", "afpfs", "cifs", "webdav"];
168 if network_types.iter().any(|t| fstype.eq_ignore_ascii_case(t)) {
169 return Err(WorktreeError::NetworkFilesystem {
170 mount_point: path.to_path_buf(),
171 });
172 }
173 }
174 }
175 }
176
177 #[cfg(target_os = "linux")]
178 {
179 if let Ok(mounts) = std::fs::read_to_string("/proc/mounts") {
181 let path_str = path.to_string_lossy();
182 let network_types = ["nfs", "nfs4", "cifs", "smbfs", "fuse.sshfs", "9p"];
183 for line in mounts.lines() {
184 let parts: Vec<&str> = line.split_whitespace().collect();
185 if parts.len() >= 3 {
186 let mount_point = parts[1];
187 let fs_type = parts[2];
188 if path_str.starts_with(mount_point)
189 && network_types.contains(&fs_type)
190 {
191 return Err(WorktreeError::NetworkFilesystem {
192 mount_point: std::path::PathBuf::from(mount_point),
193 });
194 }
195 }
196 }
197 }
198 }
199
200 Ok(())
201}
202
203pub(crate) fn check_not_wsl_cross_boundary(
206 repo: &Path,
207 worktree: &Path,
208) -> Result<(), WorktreeError> {
209 #[cfg(target_os = "linux")]
210 {
211 if let Ok(version) = std::fs::read_to_string("/proc/version") {
212 if version.contains("Microsoft") || version.contains("microsoft") {
213 let repo_on_mnt = repo.starts_with("/mnt/");
214 let wt_on_mnt = worktree.starts_with("/mnt/");
215 if repo_on_mnt != wt_on_mnt {
216 return Err(WorktreeError::WslCrossBoundary);
217 }
218 }
219 }
220 }
221
222 let _ = (repo, worktree);
224 Ok(())
225}
226
227pub(crate) fn check_bare_repo(repo: &Path) -> Result<bool, WorktreeError> {
231 let output = Command::new("git")
232 .args(["rev-parse", "--is-bare-repository"])
233 .current_dir(repo)
234 .output()
235 .map_err(|_| WorktreeError::GitNotFound)?;
236
237 if !output.status.success() {
238 return Ok(false);
239 }
240
241 let stdout = String::from_utf8_lossy(&output.stdout);
242 Ok(stdout.trim() == "true")
243}
244
245pub(crate) fn check_submodule_context(repo: &Path) -> Result<bool, WorktreeError> {
249 let output = Command::new("git")
250 .args(["rev-parse", "--show-superproject-working-tree"])
251 .current_dir(repo)
252 .output()
253 .map_err(|_| WorktreeError::GitNotFound)?;
254
255 if !output.status.success() {
256 return Ok(false);
257 }
258
259 let stdout = String::from_utf8_lossy(&output.stdout);
261 Ok(!stdout.trim().is_empty())
262}
263
264pub(crate) fn check_total_disk_usage(
271 worktrees: &[WorktreeHandle],
272 target_path: &Path,
273 max_bytes: Option<u64>,
274 threshold_percent: Option<u8>,
275) -> Result<(), WorktreeError> {
276 if max_bytes.is_none() && threshold_percent.is_none() {
277 return Ok(());
278 }
279
280 let total_bytes = crate::util::dir_size_skipping_git(worktrees.iter().map(|wt| wt.path.as_path()));
281
282 if let Some(limit) = max_bytes {
283 if total_bytes > limit {
284 return Err(WorktreeError::AggregateDiskLimitExceeded);
285 }
286 }
287
288 if let Some(pct) = threshold_percent {
289 if let Some(capacity) = crate::util::filesystem_capacity_bytes(target_path) {
290 if capacity > 0 {
291 let limit = capacity.saturating_mul(u64::from(pct)) / 100;
292 if total_bytes > limit {
293 return Err(WorktreeError::AggregateDiskLimitExceeded);
294 }
295 }
296 }
297 }
298
299 Ok(())
300}
301
302#[cfg(target_os = "windows")]
304pub(crate) fn check_not_network_junction_target(path: &Path) -> Result<(), WorktreeError> {
305 let path_str = path.to_string_lossy();
306 if path_str.starts_with("\\\\") && !path_str.starts_with("\\\\?\\") {
308 return Err(WorktreeError::NetworkJunctionTarget {
309 path: path.to_path_buf(),
310 });
311 }
312 Ok(())
313}
314
315pub(crate) fn check_git_crypt_pre_create(repo: &Path) -> Result<GitCryptStatus, WorktreeError> {
318 let gitattributes = repo.join(".gitattributes");
319 if !gitattributes.exists() {
320 return Ok(GitCryptStatus::NotUsed);
321 }
322
323 let content = std::fs::read_to_string(&gitattributes).map_err(WorktreeError::Io)?;
324
325 let has_git_crypt = content
326 .lines()
327 .any(|line| line.contains("filter=git-crypt"));
328
329 if !has_git_crypt {
330 return Ok(GitCryptStatus::NotUsed);
331 }
332
333 let git_dir_output = Command::new("git")
335 .args(["rev-parse", "--git-dir"])
336 .current_dir(repo)
337 .output()
338 .map_err(|_| WorktreeError::GitNotFound)?;
339
340 let git_dir = repo.join(String::from_utf8_lossy(&git_dir_output.stdout).trim());
341 let key_file = git_dir.join("git-crypt").join("keys").join("default");
342
343 if !key_file.exists() {
344 return Ok(GitCryptStatus::LockedNoKey);
345 }
346
347 const GIT_CRYPT_MAGIC: &[u8; 10] = b"\x00GITCRYPT\x00";
349
350 for line in content.lines() {
352 if !line.contains("filter=git-crypt") {
353 continue;
354 }
355 let pattern = line.split_whitespace().next().unwrap_or("");
357 if pattern.is_empty() {
358 continue;
359 }
360
361 let ls_output = Command::new("git")
363 .args(["ls-files", "--", pattern])
364 .current_dir(repo)
365 .output();
366
367 if let Ok(ls) = ls_output {
368 for file_path in String::from_utf8_lossy(&ls.stdout).lines() {
369 let full_path = repo.join(file_path);
370 if full_path.exists() {
371 if let Ok(true) = git::is_encrypted(&full_path, GIT_CRYPT_MAGIC) {
372 return Ok(GitCryptStatus::Locked);
373 }
374 }
375 }
376 }
377 }
378
379 Ok(GitCryptStatus::Unlocked)
380}
381
382
383pub(crate) struct PreCreateArgs<'a> {
387 pub repo: &'a Path,
388 pub branch: &'a str,
389 pub target_path: &'a Path,
390 pub caps: &'a GitCapabilities,
391 pub existing_worktrees: &'a [WorktreeHandle],
392 pub max_worktrees: usize,
393 pub min_free_disk_mb: u64,
394 pub max_total_disk_bytes: Option<u64>,
395 pub ignore_disk_limit: bool,
397 pub disk_threshold_percent: Option<u8>,
399}
400
401pub(crate) fn run_pre_create_guards(args: PreCreateArgs<'_>) -> Result<GitCryptStatus, WorktreeError> {
405 check_branch_not_checked_out(args.repo, args.branch, args.caps)?;
407
408 check_disk_space(args.target_path, args.min_free_disk_mb)?;
410
411 let active_count = args
417 .existing_worktrees
418 .iter()
419 .filter(|wt| {
420 !matches!(
421 wt.state,
422 WorktreeState::Orphaned | WorktreeState::Broken | WorktreeState::Deleted
423 )
424 })
425 .count();
426 check_worktree_count(active_count, args.max_worktrees)?;
427
428 check_path_not_exists(args.target_path)?;
430
431 check_not_nested_worktree(args.target_path, args.repo, args.existing_worktrees)?;
433
434 if let Err(e) = check_not_network_filesystem(args.target_path) {
436 eprintln!("WARNING: {e}");
437 }
440
441 check_not_wsl_cross_boundary(args.repo, args.target_path)?;
443
444 let _is_bare = check_bare_repo(args.repo)?;
446
447 if check_submodule_context(args.repo)? {
449 return Err(WorktreeError::SubmoduleContext);
450 }
451
452 if !args.ignore_disk_limit {
454 check_total_disk_usage(
455 args.existing_worktrees,
456 args.target_path,
457 args.max_total_disk_bytes,
458 args.disk_threshold_percent,
459 )?;
460 }
461
462 #[cfg(target_os = "windows")]
464 check_not_network_junction_target(args.target_path)?;
465
466 let crypt_status = check_git_crypt_pre_create(args.repo)?;
468
469 Ok(crypt_status)
470}
471
472pub(crate) fn check_not_cwd(path: &Path) -> Result<(), WorktreeError> {
478 if let Ok(cwd) = std::env::current_dir() {
479 let canon_path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
480 let canon_cwd = dunce::canonicalize(&cwd).unwrap_or(cwd);
481 if canon_cwd.starts_with(&canon_path) {
483 return Err(WorktreeError::CannotDeleteCwd);
484 }
485 }
486 Ok(())
487}
488
489pub(crate) fn check_no_uncommitted_changes(path: &Path) -> Result<(), WorktreeError> {
492 let output = Command::new("git")
493 .args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
494 .output()
495 .map_err(|_| WorktreeError::GitNotFound)?;
496
497 if !output.status.success() {
498 return Ok(()); }
500
501 let stdout = String::from_utf8_lossy(&output.stdout);
502 let files: Vec<String> = stdout
503 .lines()
504 .filter(|l| !l.is_empty())
505 .map(|l| l.to_string())
506 .collect();
507
508 if !files.is_empty() {
509 return Err(WorktreeError::UncommittedChanges { files });
510 }
511
512 Ok(())
513}
514
515fn detect_primary_branch(repo: &Path) -> String {
519 let output = Command::new("git")
520 .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
521 .current_dir(repo)
522 .output();
523
524 if let Ok(out) = output {
525 if out.status.success() {
526 let stdout = String::from_utf8_lossy(&out.stdout);
527 let trimmed = stdout.trim();
528 if let Some(branch) = trimmed.strip_prefix("refs/remotes/origin/") {
530 return branch.to_string();
531 }
532 }
533 }
534
535 let check_main = Command::new("git")
537 .args(["rev-parse", "--verify", "refs/heads/main"])
538 .current_dir(repo)
539 .output();
540 if let Ok(out) = check_main {
541 if out.status.success() {
542 return "main".to_string();
543 }
544 }
545
546 "master".to_string()
547}
548
549fn is_shallow_repo(repo: &Path) -> bool {
551 let output = Command::new("git")
552 .args(["rev-parse", "--is-shallow-repository"])
553 .current_dir(repo)
554 .output();
555 if let Ok(out) = output {
556 if out.status.success() {
557 return String::from_utf8_lossy(&out.stdout).trim() == "true";
558 }
559 }
560 false
561}
562
563pub(crate) fn five_step_unmerged_check(
573 branch: &str,
574 repo: &Path,
575 offline: bool,
576) -> Result<(), WorktreeError> {
577 let shallow = is_shallow_repo(repo);
578 let primary = detect_primary_branch(repo);
579
580 if !offline {
582 let fetch_result = Command::new("git")
583 .args(["fetch", "--prune", "origin"])
584 .current_dir(repo)
585 .output();
586 match fetch_result {
587 Ok(out) if !out.status.success() => {
588 eprintln!("WARNING: fetch failed, continuing with local refs only");
589 }
590 Err(_) => {
591 eprintln!("WARNING: fetch failed, continuing with local refs only");
592 }
593 _ => {}
594 }
595 }
596
597 if shallow {
598 eprintln!("WARNING: shallow repo detected — remote ancestor checks skipped");
599 } else {
601 let step2 = Command::new("git")
603 .args(["merge-base", "--is-ancestor", branch, &primary])
604 .current_dir(repo)
605 .output();
606 if let Ok(out) = step2 {
607 match out.status.code() {
608 Some(0) => return Ok(()), Some(1) => {} _ => {
611 eprintln!("WARNING: merge-base local check returned unexpected exit code");
612 }
613 }
614 }
615
616 let remote_primary = format!("origin/{primary}");
618 let step3 = Command::new("git")
619 .args(["merge-base", "--is-ancestor", branch, &remote_primary])
620 .current_dir(repo)
621 .output();
622 if let Ok(out) = step3 {
623 match out.status.code() {
624 Some(0) => return Ok(()), Some(1) => {} _ => {} }
628 }
629
630 let step4 = Command::new("git")
632 .args(["cherry", "-v", &remote_primary, branch])
633 .current_dir(repo)
634 .output();
635 if let Ok(out) = step4 {
636 if out.status.success() {
637 let stdout = String::from_utf8_lossy(&out.stdout);
638 let has_plus_lines = stdout.lines().any(|l| l.starts_with("+ ") || l.starts_with('+'));
639 if !has_plus_lines {
640 return Ok(());
642 }
643 }
645 }
647 }
648
649 let step5 = Command::new("git")
651 .args(["log", branch, "--not", "--remotes", "--oneline"])
652 .current_dir(repo)
653 .output();
654
655 if let Ok(out) = step5 {
656 if out.status.success() {
657 let stdout = String::from_utf8_lossy(&out.stdout);
658 let commit_count = stdout.lines().filter(|l| !l.is_empty()).count();
659 if commit_count == 0 {
660 return Ok(()); }
662 return Err(WorktreeError::UnmergedCommits {
663 branch: branch.to_string(),
664 commit_count,
665 });
666 }
667 }
668
669 Ok(())
671}
672
673pub(crate) fn check_not_locked(handle: &WorktreeHandle) -> Result<(), WorktreeError> {
675 if handle.state == WorktreeState::Locked {
676 return Err(WorktreeError::WorktreeLocked { reason: None });
677 }
678 Ok(())
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use std::path::PathBuf;
685
686 #[test]
687 fn test_check_worktree_count_under_limit() {
688 assert!(check_worktree_count(5, 20).is_ok());
689 }
690
691 #[test]
692 fn test_check_worktree_count_at_limit() {
693 let result = check_worktree_count(20, 20);
694 assert!(result.is_err());
695 assert!(matches!(
696 result.unwrap_err(),
697 WorktreeError::RateLimitExceeded { current: 20, max: 20 }
698 ));
699 }
700
701 #[test]
702 fn test_check_path_not_exists_ok() {
703 let path = PathBuf::from("/tmp/definitely_not_exists_iso_test_1234567890");
704 assert!(check_path_not_exists(&path).is_ok());
705 }
706
707 #[test]
708 fn test_check_path_not_exists_fails() {
709 let path = PathBuf::from("/tmp");
710 let result = check_path_not_exists(&path);
711 assert!(result.is_err());
712 assert!(matches!(result.unwrap_err(), WorktreeError::WorktreePathExists(_)));
713 }
714
715 #[test]
716 fn test_check_not_nested_no_worktrees() {
717 let result = check_not_nested_worktree(Path::new("/tmp/test"), Path::new("/some/repo"), &[]);
718 assert!(result.is_ok());
719 }
720
721 #[test]
722 fn test_check_not_nested_candidate_inside_existing() {
723 let base = dunce::canonicalize(std::env::temp_dir()).unwrap();
725 let existing = vec![WorktreeHandle::new(
726 base.clone(),
727 "main".to_string(),
728 String::new(),
729 WorktreeState::Active,
730 String::new(),
731 0,
732 String::new(),
733 None,
734 false,
735 None,
736 String::new(),
737 )];
738 let candidate = base.join("nested").join("wt");
739 let result = check_not_nested_worktree(&candidate, Path::new("/some/other/repo"), &existing);
741 assert!(result.is_err());
742 assert!(matches!(
743 result.unwrap_err(),
744 WorktreeError::NestedWorktree { .. }
745 ));
746 }
747
748 #[test]
749 fn test_check_bare_repo_not_bare() {
750 let result = check_bare_repo(Path::new("."));
752 assert!(result.is_ok());
753 assert!(!result.unwrap());
754 }
755
756 #[test]
757 fn test_check_submodule_not_submodule() {
758 let result = check_submodule_context(Path::new("."));
760 assert!(result.is_ok());
761 assert!(!result.unwrap());
762 }
763
764 #[test]
765 fn test_check_disk_space_permissive() {
766 let result = check_disk_space(Path::new("/tmp"), 1);
768 assert!(result.is_ok());
769 }
770
771 #[test]
772 fn test_check_disk_space_huge_requirement() {
773 let result = check_disk_space(Path::new("/tmp"), 999_000_000);
775 assert!(result.is_err());
776 assert!(matches!(result.unwrap_err(), WorktreeError::DiskSpaceLow { .. }));
777 }
778
779 #[test]
780 fn test_check_git_crypt_not_used() {
781 let result = check_git_crypt_pre_create(Path::new("."));
783 assert!(result.is_ok());
784 assert_eq!(result.unwrap(), GitCryptStatus::NotUsed);
785 }
786
787 #[test]
788 fn test_check_not_cwd_different_path() {
789 let result = check_not_cwd(Path::new("/tmp/definitely_not_cwd_12345"));
790 assert!(result.is_ok());
791 }
792
793 #[test]
794 fn test_check_not_locked_active() {
795 let handle = WorktreeHandle::new(
796 PathBuf::from("/tmp/wt"),
797 "test".to_string(),
798 String::new(),
799 WorktreeState::Active,
800 String::new(),
801 0,
802 String::new(),
803 None,
804 false,
805 None,
806 String::new(),
807 );
808 assert!(check_not_locked(&handle).is_ok());
809 }
810
811 #[test]
812 fn test_check_not_locked_locked() {
813 let handle = WorktreeHandle::new(
814 PathBuf::from("/tmp/wt"),
815 "test".to_string(),
816 String::new(),
817 WorktreeState::Locked,
818 String::new(),
819 0,
820 String::new(),
821 None,
822 false,
823 None,
824 String::new(),
825 );
826 let result = check_not_locked(&handle);
827 assert!(result.is_err());
828 assert!(matches!(result.unwrap_err(), WorktreeError::WorktreeLocked { .. }));
829 }
830
831 #[test]
832 fn test_check_total_disk_usage_no_limit() {
833 let result = check_total_disk_usage(&[], Path::new("/tmp"), None, None);
834 assert!(result.is_ok());
835 }
836
837 #[test]
838 fn test_check_branch_not_checked_out_ok() {
839 let caps = git::detect_git_version().unwrap();
841 let result = check_branch_not_checked_out(
842 Path::new("."),
843 "definitely-nonexistent-branch-xyz-123",
844 &caps,
845 );
846 assert!(result.is_ok());
847 }
848}