Skip to main content

git_workflow/commands/
worktree_pool.rs

1//! `gw worktree pool` commands — Per-leader worktree pool management
2//!
3//! Each leader worktree owns its own pool under `.worktrees/`.
4//! Pool state is derived from the filesystem, not from an inventory file.
5//! Marker files in the per-worktree git dir track acquisition state.
6
7use std::path::{Path, PathBuf};
8
9use crate::error::{GwError, Result};
10use crate::git;
11use crate::output;
12use crate::pool::{PoolEntry, PoolLock, PoolNextAction, PoolState, WorktreeStatus};
13
14/// Directory name under the per-worktree git dir for pool metadata
15const POOL_META_DIR: &str = "pool";
16
17/// Directory name under worktree root for pool worktrees
18const POOL_WORKTREES_DIR: &str = ".worktrees";
19
20/// Setup hook path relative to repo root
21const SETUP_HOOK: &str = ".gw/setup";
22
23/// Subdirectory for acquire markers
24const ACQUIRED_DIR: &str = "acquired";
25
26/// Canonicalize a path, stripping the `\\?\` prefix on Windows so that
27/// external tools (like git) can consume the path without issues.
28fn canonicalize_clean(path: &Path) -> PathBuf {
29    let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
30    #[cfg(target_os = "windows")]
31    {
32        let s = canonical.to_string_lossy();
33        if let Some(stripped) = s.strip_prefix(r"\\?\") {
34            return PathBuf::from(stripped);
35        }
36    }
37    canonical
38}
39
40/// Get the current worktree root (works from main repo or any worktree)
41fn leader_root() -> Result<PathBuf> {
42    let root = git::worktree_root()?;
43    Ok(canonicalize_clean(&root))
44}
45
46/// Get the leader name (the worktree directory name, e.g., "web-2").
47/// Sanitizes the name to be valid as part of a git branch name.
48fn leader_name() -> Result<String> {
49    let root = leader_root()?;
50    let raw = root
51        .file_name()
52        .and_then(|s| s.to_str())
53        .map(String::from)
54        .ok_or_else(|| GwError::Other("Could not determine leader name".to_string()))?;
55    // Strip leading dots (invalid in git branch names)
56    let sanitized = raw.trim_start_matches('.');
57    if sanitized.is_empty() {
58        return Err(GwError::Other(format!(
59            "Leader directory name is not valid for branch naming: {raw}"
60        )));
61    }
62    Ok(sanitized.to_string())
63}
64
65/// Pool entry name prefix for the current leader (e.g., "web-2-pool-")
66fn pool_prefix() -> Result<String> {
67    Ok(format!("{}-pool-", leader_name()?))
68}
69
70/// Get the main repository root (parent of .git), even from inside a worktree.
71fn main_repo_root() -> Result<PathBuf> {
72    let common = git::git_common_dir()?;
73    let common = canonicalize_clean(&common);
74    common
75        .parent()
76        .map(|p| p.to_path_buf())
77        .ok_or_else(|| GwError::Other("Could not determine main repository root".to_string()))
78}
79
80/// Resolve the pool metadata directory (per-worktree: {git_dir}/pool/)
81fn pool_dir() -> Result<PathBuf> {
82    let git_dir = git::git_dir()?;
83    let git_dir = canonicalize_clean(&git_dir);
84    Ok(git_dir.join(POOL_META_DIR))
85}
86
87/// Resolve the acquired markers directory
88fn acquired_dir() -> Result<PathBuf> {
89    Ok(pool_dir()?.join(ACQUIRED_DIR))
90}
91
92/// Resolve the worktrees directory ({leader_root}/.worktrees/)
93fn worktrees_dir() -> Result<PathBuf> {
94    let root = leader_root()?;
95    Ok(root.join(POOL_WORKTREES_DIR))
96}
97
98/// Run the setup hook if it exists
99fn run_setup_hook(repo_root: &Path, worktree_path: &str, verbose: bool) -> Result<()> {
100    let hook = repo_root.join(SETUP_HOOK);
101    if !hook.exists() {
102        return Ok(());
103    }
104
105    if verbose {
106        output::action(&format!("Running setup hook: {}", hook.display()));
107    }
108
109    let status = std::process::Command::new(&hook)
110        .arg(worktree_path)
111        .current_dir(worktree_path)
112        .status()?;
113
114    if !status.success() {
115        return Err(GwError::Other(format!(
116            "Setup hook failed with exit code: {}",
117            status.code().unwrap_or(-1)
118        )));
119    }
120    Ok(())
121}
122
123/// Run a git command inside `path`, returning trimmed stdout on success.
124fn git_capture(path: &Path, args: &[&str]) -> Option<String> {
125    std::process::Command::new("git")
126        .args(args)
127        .current_dir(path)
128        .output()
129        .ok()
130        .filter(|o| o.status.success())
131        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
132}
133
134/// Get the current branch of a worktree by running git in that directory
135fn worktree_current_branch(path: &Path) -> String {
136    git_capture(path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "???".to_string())
137}
138
139/// Detect an in-progress git operation (rebase/merge/cherry-pick/...) in a
140/// worktree by probing the well-known state files in its git dir.
141fn in_progress_operation(path: &Path) -> Option<String> {
142    // (git-path, human label)
143    const PROBES: &[(&str, &str)] = &[
144        ("rebase-merge", "rebase"),
145        ("rebase-apply", "rebase"),
146        ("MERGE_HEAD", "merge"),
147        ("CHERRY_PICK_HEAD", "cherry-pick"),
148        ("REVERT_HEAD", "revert"),
149        ("BISECT_LOG", "bisect"),
150    ];
151    for (git_path, label) in PROBES {
152        if let Some(resolved) = git_capture(path, &["rev-parse", "--git-path", git_path]) {
153            let candidate = PathBuf::from(&resolved);
154            let full = if candidate.is_absolute() {
155                candidate
156            } else {
157                path.join(candidate)
158            };
159            if full.exists() {
160                return Some((*label).to_string());
161            }
162        }
163    }
164    None
165}
166
167/// Inspect a pool worktree and return the reasons it is NOT in a clean,
168/// returnable state. An empty vec means the worktree is clean:
169///
170/// - checked out on its pool home branch (== entry name),
171/// - no staged/unstaged/untracked changes,
172/// - no in-progress git operation (rebase/merge/cherry-pick/...).
173///
174/// Sync state is intentionally not checked: being behind `origin` is fine
175/// because `acquire` fast-forwards the worktree before handing it out.
176fn worktree_issues(entry: &PoolEntry) -> Vec<String> {
177    let path = entry.path.as_path();
178    let mut issues = Vec::new();
179
180    if !path.exists() {
181        issues.push("worktree directory is missing".to_string());
182        return issues;
183    }
184
185    let branch = worktree_current_branch(path);
186    if branch != entry.branch {
187        issues.push(format!(
188            "on branch '{}', expected pool home branch '{}'",
189            branch, entry.branch
190        ));
191    }
192
193    match git_capture(path, &["status", "--porcelain"]) {
194        Some(s) if !s.trim().is_empty() => {
195            issues.push("has uncommitted or untracked changes".to_string());
196        }
197        None => issues.push("could not read working tree status".to_string()),
198        _ => {}
199    }
200
201    if let Some(op) = in_progress_operation(path) {
202        issues.push(format!("a {op} is in progress"));
203    }
204
205    issues
206}
207
208/// Ensure `.worktrees/` is excluded via `.git/info/exclude`.
209/// This is local-only and never pollutes `.gitignore` or the working tree.
210fn ensure_excluded() -> Result<()> {
211    let common = git::git_common_dir()?;
212    let exclude_path = common.join("info").join("exclude");
213    let entry = ".worktrees/";
214
215    if exclude_path.exists() {
216        let content = std::fs::read_to_string(&exclude_path)?;
217        if content.lines().any(|line| line.trim() == entry) {
218            return Ok(());
219        }
220        let prefix = if content.ends_with('\n') { "" } else { "\n" };
221        std::fs::write(&exclude_path, format!("{content}{prefix}{entry}\n"))?;
222    } else {
223        std::fs::create_dir_all(common.join("info"))?;
224        std::fs::write(&exclude_path, format!("{entry}\n"))?;
225    }
226
227    Ok(())
228}
229
230/// Release a single pool worktree back to the pool.
231///
232/// Just removes the acquire marker. The worktree should have been
233/// cleaned up (gw cleanup) before release.
234fn release_one(entry: &PoolEntry, acquired_dir: &Path) -> Result<()> {
235    let marker = acquired_dir.join(&entry.name);
236    if marker.exists() {
237        std::fs::remove_file(&marker)?;
238    }
239    output::success(&format!("{} released", entry.name));
240    Ok(())
241}
242
243// --- Pool commands ---
244
245/// `gw worktree pool warm <n>`
246pub fn warm(count: usize, verbose: bool) -> Result<()> {
247    if !git::is_git_repo() {
248        return Err(GwError::NotAGitRepository);
249    }
250
251    let pool_dir = pool_dir()?;
252    let wt_dir = worktrees_dir()?;
253    let acquired_dir = acquired_dir()?;
254    let repo_root = main_repo_root()?;
255    let prefix = pool_prefix()?;
256
257    println!();
258    output::info(&format!(
259        "Warming worktree pool to {} available",
260        output::bold(&count.to_string())
261    ));
262
263    // Acquire lock and scan filesystem
264    let _lock = PoolLock::acquire(&pool_dir)?;
265    let mut state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
266
267    let available = state.count_by_status(&WorktreeStatus::Available);
268    let acquired = state.count_by_status(&WorktreeStatus::Acquired);
269    let total = state.entries.len();
270    if available >= count {
271        output::success(&format!(
272            "Pool already has {available} available ({acquired} acquired, {total} total), nothing to do"
273        ));
274        return Ok(());
275    }
276
277    let to_create = count - available;
278
279    // Ensure .worktrees/ is excluded locally (via .git/info/exclude)
280    ensure_excluded()?;
281
282    // Fetch once
283    output::info("Fetching from origin...");
284    git::fetch_prune(verbose)?;
285    output::success("Fetched");
286
287    let default_remote = git::get_default_remote_branch()?;
288
289    // Create worktrees dir
290    std::fs::create_dir_all(&wt_dir)?;
291
292    let mut created = 0;
293    for i in 0..to_create {
294        let name = state.next_name(&prefix);
295        let abs_path = canonicalize_clean(&wt_dir).join(&name);
296        let abs_path_str = abs_path.to_string_lossy().to_string();
297        // Branch name = directory name (gw convention: dir name = home branch)
298        let branch = name.clone();
299
300        output::info(&format!(
301            "[{}/{}] Creating {}...",
302            i + 1,
303            to_create,
304            output::bold(&name)
305        ));
306
307        // Create the worktree
308        if let Err(e) = git::worktree_add(&abs_path_str, &branch, &default_remote, verbose) {
309            output::warn(&format!("Failed to create {name}: {e}"));
310            continue;
311        }
312
313        // Run setup hook
314        if let Err(e) = run_setup_hook(&repo_root, &abs_path_str, verbose) {
315            output::warn(&format!(
316                "Setup hook failed for {name}: {e}. Removing worktree."
317            ));
318            let _ = git::worktree_remove(&abs_path_str, verbose);
319            let _ = git::force_delete_branch(&branch, verbose);
320            continue;
321        }
322
323        // Track in-memory for next_name() to work correctly
324        state.entries.push(PoolEntry {
325            name: name.clone(),
326            path: abs_path,
327            branch,
328            status: WorktreeStatus::Available,
329            owner: None,
330        });
331        created += 1;
332
333        output::success(&format!("[{}/{}] Created {}", i + 1, to_create, name));
334    }
335
336    // Re-scan for accurate final counts
337    let final_state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
338    let total = final_state.entries.len();
339    let available = final_state.count_by_status(&WorktreeStatus::Available);
340
341    println!();
342    output::success(&format!(
343        "Pool warmed: {created} created, {available} available, {total} total"
344    ));
345
346    Ok(())
347}
348
349/// `gw worktree pool acquire`
350pub fn acquire(verbose: bool) -> Result<()> {
351    if !git::is_git_repo() {
352        return Err(GwError::NotAGitRepository);
353    }
354
355    let pool_dir = pool_dir()?;
356    let wt_dir = worktrees_dir()?;
357    let acquired_dir = acquired_dir()?;
358    let prefix = pool_prefix()?;
359
360    if !wt_dir.exists() {
361        return Err(GwError::PoolNotInitialized);
362    }
363
364    let _lock = PoolLock::acquire(&pool_dir)?;
365
366    // Ensure acquired dir exists
367    std::fs::create_dir_all(&acquired_dir)?;
368
369    let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
370
371    if state.entries.is_empty() {
372        return Err(GwError::PoolNotInitialized);
373    }
374
375    // Inspect each available worktree before handing one out. A dirty available
376    // worktree (e.g. left behind by an agent that crashed without a clean
377    // release) must not be loaned to the next agent — skip it with a warning.
378    // This is the CLI-side last line of defense; release is the first.
379    let available: Vec<&PoolEntry> = state
380        .entries
381        .iter()
382        .filter(|e| e.status == WorktreeStatus::Available)
383        .collect();
384    if available.is_empty() {
385        return Err(GwError::PoolExhausted);
386    }
387    let mut entry = None;
388    for candidate in &available {
389        let issues = worktree_issues(candidate);
390        if issues.is_empty() {
391            entry = Some(*candidate);
392            break;
393        }
394        // Warnings go to stderr so stdout stays "path only".
395        eprintln!(
396            "\x1b[0;33m\u{26a0}\x1b[0m Skipping unclean worktree {}: {}",
397            candidate.name,
398            issues.join("; ")
399        );
400    }
401    let entry = entry.ok_or(GwError::PoolNoCleanWorktree)?;
402
403    // Create marker file with leader name as owner
404    let owner = leader_name()?;
405    std::fs::write(acquired_dir.join(&entry.name), &owner)?;
406
407    // Sync worktree to latest (gw home equivalent). Fast-forward only, like
408    // `gw home`/`gw sync`: a pool home branch must never grow merge commits.
409    let wt_path = entry.path.to_string_lossy().to_string();
410    git::git_run_in_dir(&wt_path, &["fetch", "--prune"], verbose)?;
411    let default_remote = git::get_default_remote_branch()?;
412    let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
413    git::git_run_in_dir(
414        &wt_path,
415        &["pull", "--ff-only", "origin", default_branch],
416        verbose,
417    )?;
418
419    let path = entry.path.to_string_lossy().to_string();
420    let name = entry.name.clone();
421
422    let remaining = state.count_by_status(&WorktreeStatus::Available) - 1;
423    eprintln!(
424        "\x1b[0;32m\u{2713}\x1b[0m Acquired {} ({} remaining)",
425        name, remaining,
426    );
427
428    // Print ONLY the path to stdout for `path=$(gw worktree pool acquire)`
429    println!("{path}");
430
431    Ok(())
432}
433
434/// `gw worktree pool release [name]`
435///
436/// Removes acquire markers. No git operations — cleanup should have
437/// been run inside the worktree before releasing.
438pub fn release(name: Option<String>, _verbose: bool) -> Result<()> {
439    if !git::is_git_repo() {
440        return Err(GwError::NotAGitRepository);
441    }
442
443    let pool_dir = pool_dir()?;
444    let wt_dir = worktrees_dir()?;
445    let acquired_dir = acquired_dir()?;
446    let prefix = pool_prefix()?;
447
448    if !wt_dir.exists() {
449        return Err(GwError::PoolNotInitialized);
450    }
451
452    let _lock = PoolLock::acquire(&pool_dir)?;
453    let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
454
455    if state.entries.is_empty() {
456        return Err(GwError::PoolNotInitialized);
457    }
458
459    match name {
460        Some(ref n) => {
461            let entry = state
462                .find_by_name_or_path(n)
463                .ok_or_else(|| GwError::PoolWorktreeNotFound(n.clone()))?;
464
465            if entry.status != WorktreeStatus::Acquired {
466                return Err(GwError::PoolWorktreeNotAcquired(entry.name.clone()));
467            }
468
469            // Inspect before returning to the pool: an explicitly-named dirty
470            // worktree is a hard error so the caller notices and fixes it.
471            let issues = worktree_issues(entry);
472            if !issues.is_empty() {
473                return Err(GwError::PoolWorktreeDirty {
474                    name: entry.name.clone(),
475                    reason: issues.join("; "),
476                });
477            }
478
479            release_one(entry, &acquired_dir)?;
480        }
481        None => {
482            let acquired: Vec<_> = state
483                .entries
484                .iter()
485                .filter(|e| e.status == WorktreeStatus::Acquired)
486                .collect();
487
488            if acquired.is_empty() {
489                return Err(GwError::PoolNoneAcquired);
490            }
491
492            // Release every clean worktree; keep the dirty ones acquired (so
493            // their work can be inspected) and report them at the end.
494            let mut skipped = Vec::new();
495            for entry in &acquired {
496                let issues = worktree_issues(entry);
497                if issues.is_empty() {
498                    release_one(entry, &acquired_dir)?;
499                } else {
500                    output::warn(&format!("Kept {}: {}", entry.name, issues.join("; ")));
501                    skipped.push(entry.name.clone());
502                }
503            }
504
505            if !skipped.is_empty() {
506                return Err(GwError::Other(format!(
507                    "Kept {} unclean worktree(s) acquired: {}. Run `gw cleanup` inside each \
508                     (or fix it), then `gw worktree pool release <name>`.",
509                    skipped.len(),
510                    skipped.join(", ")
511                )));
512            }
513        }
514    }
515
516    // Re-scan for final counts
517    let final_state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
518    let available = final_state.count_by_status(&WorktreeStatus::Available);
519    let acquired_count = final_state.count_by_status(&WorktreeStatus::Acquired);
520    let total = final_state.entries.len();
521
522    println!();
523    output::success(&format!(
524        "Pool: {} available, {} acquired, {} total",
525        available, acquired_count, total
526    ));
527
528    Ok(())
529}
530
531/// `gw worktree pool status`
532pub fn status(verbose: bool) -> Result<()> {
533    if !git::is_git_repo() {
534        return Err(GwError::NotAGitRepository);
535    }
536
537    let wt_dir = worktrees_dir()?;
538    let acquired_dir = acquired_dir()?;
539    let prefix = pool_prefix()?;
540
541    if !wt_dir.exists() {
542        return Err(GwError::PoolNotInitialized);
543    }
544
545    // Read-only — no lock needed
546    let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
547
548    if state.entries.is_empty() {
549        return Err(GwError::PoolNotInitialized);
550    }
551
552    let available = state.count_by_status(&WorktreeStatus::Available);
553    let acquired = state.count_by_status(&WorktreeStatus::Acquired);
554    let total = state.entries.len();
555
556    println!();
557    output::info(&format!(
558        "Pool: {} available, {} acquired, {} total",
559        output::bold(&available.to_string()),
560        output::bold(&acquired.to_string()),
561        output::bold(&total.to_string()),
562    ));
563
564    if acquired > 0 {
565        println!();
566        let header = format!("{:<24} {}", "NAME", "BRANCH");
567        println!("{header}");
568        println!("{}", "-".repeat(48));
569
570        for entry in &state.entries {
571            if entry.status != WorktreeStatus::Acquired {
572                continue;
573            }
574            let branch = worktree_current_branch(&entry.path);
575            let branch_display = if branch == entry.name {
576                "(idle)".to_string()
577            } else {
578                branch
579            };
580            println!("{:<24} {}", entry.name, branch_display);
581            println!("    {}", entry.path.display());
582        }
583    }
584
585    if verbose {
586        // --verbose: show all entries
587        println!();
588        output::info("All entries:");
589        println!();
590        let header = format!("{:<24} {:<12} {:<24}", "NAME", "STATUS", "BRANCH");
591        println!("{header}");
592        println!("{}", "-".repeat(60));
593
594        for entry in &state.entries {
595            let branch = if entry.status == WorktreeStatus::Acquired {
596                worktree_current_branch(&entry.path)
597            } else {
598                entry.branch.clone()
599            };
600            println!("{:<24} {:<12} {}", entry.name, entry.status, branch);
601        }
602    }
603
604    // Show next action
605    let next = state.next_action();
606    println!();
607    display_pool_next_action(&next);
608
609    println!();
610    Ok(())
611}
612
613fn display_pool_next_action(action: &PoolNextAction) {
614    match action {
615        PoolNextAction::WarmPool => {
616            output::action("Next: warm the pool");
617            println!("  gw worktree pool warm <count>");
618        }
619        PoolNextAction::Ready { available } => {
620            output::action(&format!("Ready: {} worktree(s) available", available));
621            println!("  gw worktree pool acquire");
622            println!("  gw worktree pool release [name]");
623        }
624        PoolNextAction::Exhausted { acquired } => {
625            output::action(&format!(
626                "All {} worktree(s) acquired. Release or warm more.",
627                acquired
628            ));
629            println!("  gw worktree pool release [name]");
630            println!("  gw worktree pool warm <count>");
631        }
632        PoolNextAction::AllIdle { available } => {
633            output::action(&format!(
634                "All {} worktree(s) idle. Acquire or drain.",
635                available
636            ));
637            println!("  gw worktree pool acquire");
638            println!("  gw worktree pool drain");
639        }
640    }
641}
642
643/// `gw worktree pool drain [--force]`
644pub fn drain(force: bool, verbose: bool) -> Result<()> {
645    if !git::is_git_repo() {
646        return Err(GwError::NotAGitRepository);
647    }
648
649    let pool_dir = pool_dir()?;
650    let wt_dir = worktrees_dir()?;
651    let acquired_dir = acquired_dir()?;
652    let prefix = pool_prefix()?;
653    // Resolve all paths upfront — the cwd might be inside a pool worktree
654    // that we're about to delete.
655    let leader = leader_root()?;
656    let leader_str = leader.to_string_lossy().to_string();
657
658    if !wt_dir.exists() {
659        return Err(GwError::PoolNotInitialized);
660    }
661
662    println!();
663    output::info("Draining worktree pool...");
664
665    let _lock = PoolLock::acquire(&pool_dir)?;
666    let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
667
668    if state.entries.is_empty() {
669        return Err(GwError::PoolNotInitialized);
670    }
671
672    // Check for acquired worktrees
673    let acquired = state.count_by_status(&WorktreeStatus::Acquired);
674    if acquired > 0 && !force {
675        return Err(GwError::PoolHasAcquiredWorktrees(acquired));
676    }
677
678    let total = state.entries.len();
679
680    for (i, entry) in state.entries.iter().enumerate() {
681        output::info(&format!(
682            "[{}/{}] Removing {}...",
683            i + 1,
684            total,
685            output::bold(&entry.name)
686        ));
687
688        let path_str = entry.path.to_string_lossy().to_string();
689
690        // Remove the worktree (run from leader root so it works even if cwd is deleted)
691        if let Err(e) = git::git_run_in_dir(
692            &leader_str,
693            &["worktree", "remove", "--force", &path_str],
694            verbose,
695        ) {
696            output::warn(&format!("Failed to remove worktree {}: {e}", entry.name));
697            let _ = std::fs::remove_dir_all(&entry.path);
698        }
699
700        // Delete the pool branch
701        if let Err(e) = git::git_run_in_dir(&leader_str, &["branch", "-D", &entry.branch], verbose)
702        {
703            output::warn(&format!("Failed to delete branch {}: {e}", entry.branch));
704        }
705
706        // Remove acquired marker if present
707        let marker = acquired_dir.join(&entry.name);
708        let _ = std::fs::remove_file(&marker);
709
710        output::success(&format!("[{}/{}] Removed {}", i + 1, total, entry.name));
711    }
712
713    // Clean up pool metadata
714    if acquired_dir.exists() {
715        let _ = std::fs::remove_dir_all(&acquired_dir);
716    }
717    let _ = std::fs::remove_file(pool_dir.join("pool.lock"));
718
719    // Prune worktree references
720    git::git_run_in_dir(&leader_str, &["worktree", "prune"], verbose)?;
721
722    // Remove empty directories
723    if wt_dir.exists() {
724        let _ = std::fs::remove_dir(&wt_dir);
725    }
726    drop(_lock);
727    let _ = std::fs::remove_dir(&pool_dir);
728
729    println!();
730    output::success(&format!("Drained {total} worktree(s) from pool"));
731
732    Ok(())
733}