Skip to main content

iso_code/
guards.rs

1//! Pre-create and pre-delete safety guards.
2//!
3//! Internal functions — not part of the public API. The pre-create guards
4//! are ordered: callers must invoke them via `run_pre_create_guards` so the
5//! cheap checks short-circuit before any expensive filesystem probes run.
6
7use std::path::Path;
8use std::process::Command;
9
10use crate::error::WorktreeError;
11use crate::git;
12use crate::types::{GitCapabilities, GitCryptStatus, WorktreeHandle, WorktreeState};
13
14/// Guard 1: Branch not already checked out in any worktree.
15/// Runs `git worktree list --porcelain` and scans for the branch.
16pub(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
33/// Guard 2: Minimum free disk space.
34/// Uses sysinfo disk info on the target path's mount point.
35pub(crate) fn check_disk_space(target_path: &Path, required_mb: u64) -> Result<(), WorktreeError> {
36    // Use sysinfo to check available disk space
37    use sysinfo::Disks;
38
39    let check_path = if target_path.exists() {
40        target_path.to_path_buf()
41    } else {
42        // If target doesn't exist yet, check parent
43        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    // Find the disk containing the path by longest mount point prefix match
52    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    // If we can't determine disk space, don't block — be permissive
76
77    Ok(())
78}
79
80/// Guard 3: Worktree count limit.
81pub(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
88/// Guard 4: Target path does not already exist on disk.
89pub(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
96/// Reject targets that nest inside an existing worktree (or would contain one).
97///
98/// Uses `dunce::canonicalize` and [`Path::starts_with`] so the comparison is
99/// bounded by full path components rather than raw string prefixes. The
100/// primary worktree (repo root) is excluded because every new worktree is by
101/// definition "inside" it.
102pub(crate) fn check_not_nested_worktree(
103    candidate: &Path,
104    repo_root: &Path,
105    existing: &[WorktreeHandle],
106) -> Result<(), WorktreeError> {
107    // The candidate path doesn't exist yet (guard 4 verified this), so canonicalize
108    // will fail. Instead, canonicalize the nearest existing ancestor and re-append
109    // the remaining components to get a reliable absolute path for starts_with checks.
110    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        // Skip the primary worktree (the repo root itself). The primary worktree is
129        // always at the repo root and all worktree paths are naturally "inside" it.
130        if canon_existing == canon_repo {
131            continue;
132        }
133
134        // Case 1: New worktree would be inside an existing one.
135        if canon_candidate.starts_with(&canon_existing) {
136            return Err(WorktreeError::NestedWorktree {
137                parent: wt.path.clone(),
138            });
139        }
140        // Case 2: An existing worktree would be inside the new one.
141        if canon_existing.starts_with(&canon_candidate) {
142            return Err(WorktreeError::NestedWorktree {
143                parent: canon_candidate,
144            });
145        }
146    }
147    Ok(())
148}
149
150/// Guard 6: Not a network filesystem (warning-level, not hard block).
151/// On macOS: uses statfs() f_fstypename.
152/// On Linux: parses /proc/mounts or uses statfs.
153pub(crate) fn check_not_network_filesystem(path: &Path) -> Result<(), WorktreeError> {
154    // Platform-specific detection
155    #[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        // Parse /proc/mounts for network filesystem types
180        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
203/// Guard 7: Not crossing WSL/Windows filesystem boundary.
204/// Detects WSL via /proc/version containing "Microsoft".
205pub(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    // Not WSL on non-Linux platforms
223    let _ = (repo, worktree);
224    Ok(())
225}
226
227/// Guard 8: Bare repository detection.
228/// Runs `git rev-parse --is-bare-repository`.
229/// Returns true if bare; caller adjusts path defaults.
230pub(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
245/// Guard 9: Submodule context detection.
246/// Runs `git rev-parse --show-superproject-working-tree`.
247/// Returns true if inside a submodule.
248pub(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    // If output is non-empty, we're inside a submodule
260    let stdout = String::from_utf8_lossy(&output.stdout);
261    Ok(!stdout.trim().is_empty())
262}
263
264/// Guard 10: Aggregate disk usage check.
265///
266/// Evaluates up to two independent limits:
267///   * `max_bytes` — hard cap on aggregate worktree bytes (Config.max_total_disk_bytes).
268///   * `threshold_percent` — refuse if aggregate usage exceeds this percentage
269///     of the filesystem capacity hosting `target_path` (Config.disk_threshold_percent).
270pub(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/// Guard 11 (Windows only): Junction target is not a network path.
303#[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    // Network paths start with \\ but not \\?\
307    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
315/// Guard 12: git-crypt pre-create check.
316/// Parses .gitattributes for `filter=git-crypt` patterns.
317pub(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    // Check for key file
334    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    // Check if any git-crypt files are still encrypted by reading their headers
348    const GIT_CRYPT_MAGIC: &[u8; 10] = b"\x00GITCRYPT\x00";
349
350    // Find files with git-crypt filter
351    for line in content.lines() {
352        if !line.contains("filter=git-crypt") {
353            continue;
354        }
355        // Extract the pattern (first field before any attributes)
356        let pattern = line.split_whitespace().next().unwrap_or("");
357        if pattern.is_empty() {
358            continue;
359        }
360
361        // Use git ls-files to find matching files
362        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
383/// Arguments for `run_pre_create_guards`. Keeps the guard runner readable
384/// and maps directly onto Config / CreateOptions fields so wiring new knobs
385/// doesn't require touching every call site.
386pub(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    /// When true, Guard 10 (aggregate disk limits) is skipped entirely.
396    pub ignore_disk_limit: bool,
397    /// When set, Guard 10 additionally enforces a percentage-of-filesystem cap.
398    pub disk_threshold_percent: Option<u8>,
399}
400
401/// Run every pre-create guard in order. The ordering is load-bearing: later
402/// guards assume invariants established by earlier ones. Returns the detected
403/// [`GitCryptStatus`] so callers can decide whether to proceed.
404pub(crate) fn run_pre_create_guards(args: PreCreateArgs<'_>) -> Result<GitCryptStatus, WorktreeError> {
405    // 1. Branch not already checked out
406    check_branch_not_checked_out(args.repo, args.branch, args.caps)?;
407
408    // 2. Minimum free disk space
409    check_disk_space(args.target_path, args.min_free_disk_mb)?;
410
411    // 3. Worktree count limit.
412    // Count every worktree that still occupies a slot on disk or in git's
413    // registry. Locked worktrees absolutely count (they hold resources and
414    // can't be evicted by gc). Orphaned/Broken/Deleted are about to be
415    // reaped or are already gone, so they don't block new creation.
416    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    // 4. Target path does not already exist
429    check_path_not_exists(args.target_path)?;
430
431    // 5. Not nested inside existing worktree (bidirectional)
432    check_not_nested_worktree(args.target_path, args.repo, args.existing_worktrees)?;
433
434    // 6. Not a network filesystem (warning-level)
435    if let Err(e) = check_not_network_filesystem(args.target_path) {
436        eprintln!("WARNING: {e}");
437        // Network-FS placement is discouraged but not fatal — lock semantics
438        // and rename atomicity may degrade, so we warn and continue.
439    }
440
441    // 7. Not crossing WSL/Windows boundary
442    check_not_wsl_cross_boundary(args.repo, args.target_path)?;
443
444    // 8. Bare repo detection (adjusts behavior, doesn't block)
445    let _is_bare = check_bare_repo(args.repo)?;
446
447    // 9. Submodule context
448    if check_submodule_context(args.repo)? {
449        return Err(WorktreeError::SubmoduleContext);
450    }
451
452    // 10. Aggregate disk usage (opt-out via CreateOptions.ignore_disk_limit)
453    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    // 11. Windows junction target check (Windows only)
463    #[cfg(target_os = "windows")]
464    check_not_network_junction_target(args.target_path)?;
465
466    // 12. git-crypt pre-create check
467    let crypt_status = check_git_crypt_pre_create(args.repo)?;
468
469    Ok(crypt_status)
470}
471
472// ── Pre-Delete Guards ──────────────────────────────────────────────────
473
474/// Reject deletions that would unmount the caller's current working directory
475/// or any of its ancestors — doing so would leave the calling shell stranded
476/// in a directory that no longer exists.
477pub(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        // Block if path IS the CWD, or if CWD is inside the path (path is a parent of CWD).
482        if canon_cwd.starts_with(&canon_path) {
483            return Err(WorktreeError::CannotDeleteCwd);
484        }
485    }
486    Ok(())
487}
488
489/// Pre-delete guard 2: No uncommitted changes.
490/// Runs `git -C <path> status --porcelain`.
491pub(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(()); // If status fails, don't block
499    }
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
515/// Detect the primary branch name.
516/// Runs `git symbolic-ref refs/remotes/origin/HEAD`, strips prefix.
517/// Falls back to "main" then "master".
518fn 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            // Strip refs/remotes/origin/ prefix
529            if let Some(branch) = trimmed.strip_prefix("refs/remotes/origin/") {
530                return branch.to_string();
531            }
532        }
533    }
534
535    // Fallback: check if "main" exists as a local branch, else "master"
536    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
549/// Check if the repository is shallow.
550fn 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
563/// Five-step unmerged-commit decision tree used before delete.
564///
565/// Classifies the branch against its upstream and integration branches to
566/// decide whether the worktree holds work that would be lost by deletion.
567/// Skipped entirely when `DeleteOptions::force` is true.
568///
569/// When `offline` is true, step 1 (`git fetch --prune origin`) is skipped
570/// so deletes and gc don't stall on network I/O. Steps 2–5 still run against
571/// whatever refs are already local.
572pub(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    // Step 1: git fetch --prune origin (skipped when offline).
581    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        // Skip Steps 2-4, go directly to Step 5
600    } else {
601        // Step 2: git merge-base --is-ancestor <branch> <primary_branch>
602        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(()), // SAFE TO DELETE
609                Some(1) => {}             // not merged locally, continue
610                _ => {
611                    eprintln!("WARNING: merge-base local check returned unexpected exit code");
612                }
613            }
614        }
615
616        // Step 3: git merge-base --is-ancestor <branch> origin/<primary_branch>
617        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(()), // SAFE TO DELETE
625                Some(1) => {}             // not merged into remote, continue
626                _ => {}                   // no remote exists, continue
627            }
628        }
629
630        // Step 4: git cherry -v origin/<primary_branch> <branch>
631        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                    // All patches are upstream (only '-' lines or empty)
641                    return Ok(());
642                }
643                // '+' lines present → unique commits remain → continue to Step 5
644            }
645            // Command fails (no remote) → continue to Step 5
646        }
647    }
648
649    // Step 5: git log <branch> --not --remotes --oneline
650    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(()); // SAFE TO DELETE
661            }
662            return Err(WorktreeError::UnmergedCommits {
663                branch: branch.to_string(),
664                commit_count,
665            });
666        }
667    }
668
669    // If step 5 fails entirely, don't block deletion
670    Ok(())
671}
672
673/// Pre-delete guard 3: Worktree not locked.
674pub(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        // Use a real existing directory as the "existing worktree"
724        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        // Use a repo_root that differs from the existing worktree so it's not skipped
740        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        // Run against this project's repo — it's not bare
751        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        // This project is not a submodule
759        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        // Should pass for reasonable amounts on /tmp
767        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        // 999 TB requirement should fail
774        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        // This repo doesn't use git-crypt
782        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        // A branch that definitely doesn't exist
840        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}