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/// Get the current branch of a worktree by running git in that directory
124fn worktree_current_branch(path: &Path) -> String {
125    let path_str = path.to_string_lossy();
126    std::process::Command::new("git")
127        .args(["rev-parse", "--abbrev-ref", "HEAD"])
128        .current_dir(&*path_str)
129        .output()
130        .ok()
131        .filter(|o| o.status.success())
132        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
133        .unwrap_or_else(|| "???".to_string())
134}
135
136/// Ensure `.worktrees/` is excluded via `.git/info/exclude`.
137/// This is local-only and never pollutes `.gitignore` or the working tree.
138fn ensure_excluded() -> Result<()> {
139    let common = git::git_common_dir()?;
140    let exclude_path = common.join("info").join("exclude");
141    let entry = ".worktrees/";
142
143    if exclude_path.exists() {
144        let content = std::fs::read_to_string(&exclude_path)?;
145        if content.lines().any(|line| line.trim() == entry) {
146            return Ok(());
147        }
148        let prefix = if content.ends_with('\n') { "" } else { "\n" };
149        std::fs::write(&exclude_path, format!("{content}{prefix}{entry}\n"))?;
150    } else {
151        std::fs::create_dir_all(common.join("info"))?;
152        std::fs::write(&exclude_path, format!("{entry}\n"))?;
153    }
154
155    Ok(())
156}
157
158/// Release a single pool worktree back to the pool.
159///
160/// Just removes the acquire marker. The worktree should have been
161/// cleaned up (gw cleanup) before release.
162fn release_one(entry: &PoolEntry, acquired_dir: &Path) -> Result<()> {
163    let marker = acquired_dir.join(&entry.name);
164    if marker.exists() {
165        std::fs::remove_file(&marker)?;
166    }
167    output::success(&format!("{} released", entry.name));
168    Ok(())
169}
170
171// --- Pool commands ---
172
173/// `gw worktree pool warm <n>`
174pub fn warm(count: usize, verbose: bool) -> Result<()> {
175    if !git::is_git_repo() {
176        return Err(GwError::NotAGitRepository);
177    }
178
179    let pool_dir = pool_dir()?;
180    let wt_dir = worktrees_dir()?;
181    let acquired_dir = acquired_dir()?;
182    let repo_root = main_repo_root()?;
183    let prefix = pool_prefix()?;
184
185    println!();
186    output::info(&format!(
187        "Warming worktree pool to {} available",
188        output::bold(&count.to_string())
189    ));
190
191    // Acquire lock and scan filesystem
192    let _lock = PoolLock::acquire(&pool_dir)?;
193    let mut state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
194
195    let available = state.count_by_status(&WorktreeStatus::Available);
196    let acquired = state.count_by_status(&WorktreeStatus::Acquired);
197    let total = state.entries.len();
198    if available >= count {
199        output::success(&format!(
200            "Pool already has {available} available ({acquired} acquired, {total} total), nothing to do"
201        ));
202        return Ok(());
203    }
204
205    let to_create = count - available;
206
207    // Ensure .worktrees/ is excluded locally (via .git/info/exclude)
208    ensure_excluded()?;
209
210    // Fetch once
211    output::info("Fetching from origin...");
212    git::fetch_prune(verbose)?;
213    output::success("Fetched");
214
215    let default_remote = git::get_default_remote_branch()?;
216
217    // Create worktrees dir
218    std::fs::create_dir_all(&wt_dir)?;
219
220    let mut created = 0;
221    for i in 0..to_create {
222        let name = state.next_name(&prefix);
223        let abs_path = canonicalize_clean(&wt_dir).join(&name);
224        let abs_path_str = abs_path.to_string_lossy().to_string();
225        // Branch name = directory name (gw convention: dir name = home branch)
226        let branch = name.clone();
227
228        output::info(&format!(
229            "[{}/{}] Creating {}...",
230            i + 1,
231            to_create,
232            output::bold(&name)
233        ));
234
235        // Create the worktree
236        if let Err(e) = git::worktree_add(&abs_path_str, &branch, &default_remote, verbose) {
237            output::warn(&format!("Failed to create {name}: {e}"));
238            continue;
239        }
240
241        // Run setup hook
242        if let Err(e) = run_setup_hook(&repo_root, &abs_path_str, verbose) {
243            output::warn(&format!(
244                "Setup hook failed for {name}: {e}. Removing worktree."
245            ));
246            let _ = git::worktree_remove(&abs_path_str, verbose);
247            let _ = git::force_delete_branch(&branch, verbose);
248            continue;
249        }
250
251        // Track in-memory for next_name() to work correctly
252        state.entries.push(PoolEntry {
253            name: name.clone(),
254            path: abs_path,
255            branch,
256            status: WorktreeStatus::Available,
257            owner: None,
258        });
259        created += 1;
260
261        output::success(&format!("[{}/{}] Created {}", i + 1, to_create, name));
262    }
263
264    // Re-scan for accurate final counts
265    let final_state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
266    let total = final_state.entries.len();
267    let available = final_state.count_by_status(&WorktreeStatus::Available);
268
269    println!();
270    output::success(&format!(
271        "Pool warmed: {created} created, {available} available, {total} total"
272    ));
273
274    Ok(())
275}
276
277/// `gw worktree pool acquire`
278pub fn acquire(verbose: bool) -> Result<()> {
279    if !git::is_git_repo() {
280        return Err(GwError::NotAGitRepository);
281    }
282
283    let pool_dir = pool_dir()?;
284    let wt_dir = worktrees_dir()?;
285    let acquired_dir = acquired_dir()?;
286    let prefix = pool_prefix()?;
287
288    if !wt_dir.exists() {
289        return Err(GwError::PoolNotInitialized);
290    }
291
292    let _lock = PoolLock::acquire(&pool_dir)?;
293
294    // Ensure acquired dir exists
295    std::fs::create_dir_all(&acquired_dir)?;
296
297    let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
298
299    if state.entries.is_empty() {
300        return Err(GwError::PoolNotInitialized);
301    }
302
303    let entry = state.find_available().ok_or(GwError::PoolExhausted)?;
304
305    // Create marker file with leader name as owner
306    let owner = leader_name()?;
307    std::fs::write(acquired_dir.join(&entry.name), &owner)?;
308
309    // Sync worktree to latest (gw home equivalent)
310    let wt_path = entry.path.to_string_lossy().to_string();
311    git::git_run_in_dir(&wt_path, &["fetch", "--prune"], verbose)?;
312    let default_remote = git::get_default_remote_branch()?;
313    let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
314    git::git_run_in_dir(&wt_path, &["pull", "origin", default_branch], verbose)?;
315
316    let path = entry.path.to_string_lossy().to_string();
317    let name = entry.name.clone();
318
319    let remaining = state.count_by_status(&WorktreeStatus::Available) - 1;
320    eprintln!(
321        "\x1b[0;32m\u{2713}\x1b[0m Acquired {} ({} remaining)",
322        name, remaining,
323    );
324
325    // Print ONLY the path to stdout for `path=$(gw worktree pool acquire)`
326    println!("{path}");
327
328    Ok(())
329}
330
331/// `gw worktree pool release [name]`
332///
333/// Removes acquire markers. No git operations — cleanup should have
334/// been run inside the worktree before releasing.
335pub fn release(name: Option<String>, _verbose: bool) -> Result<()> {
336    if !git::is_git_repo() {
337        return Err(GwError::NotAGitRepository);
338    }
339
340    let pool_dir = pool_dir()?;
341    let wt_dir = worktrees_dir()?;
342    let acquired_dir = acquired_dir()?;
343    let prefix = pool_prefix()?;
344
345    if !wt_dir.exists() {
346        return Err(GwError::PoolNotInitialized);
347    }
348
349    let _lock = PoolLock::acquire(&pool_dir)?;
350    let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
351
352    if state.entries.is_empty() {
353        return Err(GwError::PoolNotInitialized);
354    }
355
356    match name {
357        Some(ref n) => {
358            let entry = state
359                .find_by_name_or_path(n)
360                .ok_or_else(|| GwError::PoolWorktreeNotFound(n.clone()))?;
361
362            if entry.status != WorktreeStatus::Acquired {
363                return Err(GwError::PoolWorktreeNotAcquired(entry.name.clone()));
364            }
365
366            release_one(entry, &acquired_dir)?;
367        }
368        None => {
369            let acquired: Vec<_> = state
370                .entries
371                .iter()
372                .filter(|e| e.status == WorktreeStatus::Acquired)
373                .collect();
374
375            if acquired.is_empty() {
376                return Err(GwError::PoolNoneAcquired);
377            }
378
379            for entry in &acquired {
380                release_one(entry, &acquired_dir)?;
381            }
382        }
383    }
384
385    // Re-scan for final counts
386    let final_state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
387    let available = final_state.count_by_status(&WorktreeStatus::Available);
388    let acquired_count = final_state.count_by_status(&WorktreeStatus::Acquired);
389    let total = final_state.entries.len();
390
391    println!();
392    output::success(&format!(
393        "Pool: {} available, {} acquired, {} total",
394        available, acquired_count, total
395    ));
396
397    Ok(())
398}
399
400/// `gw worktree pool status`
401pub fn status(verbose: bool) -> Result<()> {
402    if !git::is_git_repo() {
403        return Err(GwError::NotAGitRepository);
404    }
405
406    let wt_dir = worktrees_dir()?;
407    let acquired_dir = acquired_dir()?;
408    let prefix = pool_prefix()?;
409
410    if !wt_dir.exists() {
411        return Err(GwError::PoolNotInitialized);
412    }
413
414    // Read-only — no lock needed
415    let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
416
417    if state.entries.is_empty() {
418        return Err(GwError::PoolNotInitialized);
419    }
420
421    let available = state.count_by_status(&WorktreeStatus::Available);
422    let acquired = state.count_by_status(&WorktreeStatus::Acquired);
423    let total = state.entries.len();
424
425    println!();
426    output::info(&format!(
427        "Pool: {} available, {} acquired, {} total",
428        output::bold(&available.to_string()),
429        output::bold(&acquired.to_string()),
430        output::bold(&total.to_string()),
431    ));
432
433    if acquired > 0 {
434        println!();
435        let header = format!("{:<24} {}", "NAME", "BRANCH");
436        println!("{header}");
437        println!("{}", "-".repeat(48));
438
439        for entry in &state.entries {
440            if entry.status != WorktreeStatus::Acquired {
441                continue;
442            }
443            let branch = worktree_current_branch(&entry.path);
444            let branch_display = if branch == entry.name {
445                "(idle)".to_string()
446            } else {
447                branch
448            };
449            println!("{:<24} {}", entry.name, branch_display);
450            println!("    {}", entry.path.display());
451        }
452    }
453
454    if verbose {
455        // --verbose: show all entries
456        println!();
457        output::info("All entries:");
458        println!();
459        let header = format!("{:<24} {:<12} {:<24}", "NAME", "STATUS", "BRANCH");
460        println!("{header}");
461        println!("{}", "-".repeat(60));
462
463        for entry in &state.entries {
464            let branch = if entry.status == WorktreeStatus::Acquired {
465                worktree_current_branch(&entry.path)
466            } else {
467                entry.branch.clone()
468            };
469            println!("{:<24} {:<12} {}", entry.name, entry.status, branch);
470        }
471    }
472
473    // Show next action
474    let next = state.next_action();
475    println!();
476    display_pool_next_action(&next);
477
478    println!();
479    Ok(())
480}
481
482fn display_pool_next_action(action: &PoolNextAction) {
483    match action {
484        PoolNextAction::WarmPool => {
485            output::action("Next: warm the pool");
486            println!("  gw worktree pool warm <count>");
487        }
488        PoolNextAction::Ready { available } => {
489            output::action(&format!("Ready: {} worktree(s) available", available));
490            println!("  gw worktree pool acquire");
491            println!("  gw worktree pool release [name]");
492        }
493        PoolNextAction::Exhausted { acquired } => {
494            output::action(&format!(
495                "All {} worktree(s) acquired. Release or warm more.",
496                acquired
497            ));
498            println!("  gw worktree pool release [name]");
499            println!("  gw worktree pool warm <count>");
500        }
501        PoolNextAction::AllIdle { available } => {
502            output::action(&format!(
503                "All {} worktree(s) idle. Acquire or drain.",
504                available
505            ));
506            println!("  gw worktree pool acquire");
507            println!("  gw worktree pool drain");
508        }
509    }
510}
511
512/// `gw worktree pool drain [--force]`
513pub fn drain(force: bool, verbose: bool) -> Result<()> {
514    if !git::is_git_repo() {
515        return Err(GwError::NotAGitRepository);
516    }
517
518    let pool_dir = pool_dir()?;
519    let wt_dir = worktrees_dir()?;
520    let acquired_dir = acquired_dir()?;
521    let prefix = pool_prefix()?;
522    // Resolve all paths upfront — the cwd might be inside a pool worktree
523    // that we're about to delete.
524    let leader = leader_root()?;
525    let leader_str = leader.to_string_lossy().to_string();
526
527    if !wt_dir.exists() {
528        return Err(GwError::PoolNotInitialized);
529    }
530
531    println!();
532    output::info("Draining worktree pool...");
533
534    let _lock = PoolLock::acquire(&pool_dir)?;
535    let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
536
537    if state.entries.is_empty() {
538        return Err(GwError::PoolNotInitialized);
539    }
540
541    // Check for acquired worktrees
542    let acquired = state.count_by_status(&WorktreeStatus::Acquired);
543    if acquired > 0 && !force {
544        return Err(GwError::PoolHasAcquiredWorktrees(acquired));
545    }
546
547    let total = state.entries.len();
548
549    for (i, entry) in state.entries.iter().enumerate() {
550        output::info(&format!(
551            "[{}/{}] Removing {}...",
552            i + 1,
553            total,
554            output::bold(&entry.name)
555        ));
556
557        let path_str = entry.path.to_string_lossy().to_string();
558
559        // Remove the worktree (run from leader root so it works even if cwd is deleted)
560        if let Err(e) = git::git_run_in_dir(
561            &leader_str,
562            &["worktree", "remove", "--force", &path_str],
563            verbose,
564        ) {
565            output::warn(&format!("Failed to remove worktree {}: {e}", entry.name));
566            let _ = std::fs::remove_dir_all(&entry.path);
567        }
568
569        // Delete the pool branch
570        if let Err(e) = git::git_run_in_dir(&leader_str, &["branch", "-D", &entry.branch], verbose)
571        {
572            output::warn(&format!("Failed to delete branch {}: {e}", entry.branch));
573        }
574
575        // Remove acquired marker if present
576        let marker = acquired_dir.join(&entry.name);
577        let _ = std::fs::remove_file(&marker);
578
579        output::success(&format!("[{}/{}] Removed {}", i + 1, total, entry.name));
580    }
581
582    // Clean up pool metadata
583    if acquired_dir.exists() {
584        let _ = std::fs::remove_dir_all(&acquired_dir);
585    }
586    let _ = std::fs::remove_file(pool_dir.join("pool.lock"));
587
588    // Prune worktree references
589    git::git_run_in_dir(&leader_str, &["worktree", "prune"], verbose)?;
590
591    // Remove empty directories
592    if wt_dir.exists() {
593        let _ = std::fs::remove_dir(&wt_dir);
594    }
595    drop(_lock);
596    let _ = std::fs::remove_dir(&pool_dir);
597
598    println!();
599    output::success(&format!("Drained {total} worktree(s) from pool"));
600
601    Ok(())
602}