Skip to main content

git_workflow/commands/
worktree_pool.rs

1//! `gw worktree pool` commands — Worktree pool management
2//!
3//! Pool state is derived from the filesystem, not from an inventory file.
4//! Marker files in `.git/worktree-pool/acquired/` track acquisition state.
5
6use std::path::{Path, PathBuf};
7
8use crate::error::{GwError, Result};
9use crate::git;
10use crate::output;
11use crate::pool::{PoolEntry, PoolLock, PoolNextAction, PoolState, WorktreeStatus};
12
13/// Directory name under git_common_dir for pool metadata
14const POOL_META_DIR: &str = "worktree-pool";
15
16/// Directory name under repo root for pool worktrees
17const POOL_WORKTREES_DIR: &str = ".worktrees";
18
19/// Setup hook path relative to repo root
20const SETUP_HOOK: &str = ".gw/setup";
21
22/// Subdirectory for acquire markers
23const ACQUIRED_DIR: &str = "acquired";
24
25/// Canonicalize a path, stripping the `\\?\` prefix on Windows so that
26/// external tools (like git) can consume the path without issues.
27fn canonicalize_clean(path: &Path) -> PathBuf {
28    let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
29    #[cfg(target_os = "windows")]
30    {
31        let s = canonical.to_string_lossy();
32        if let Some(stripped) = s.strip_prefix(r"\\?\") {
33            return PathBuf::from(stripped);
34        }
35    }
36    canonical
37}
38
39/// Get the main repository root (parent of .git), even from inside a worktree.
40fn main_repo_root() -> Result<PathBuf> {
41    let common = git::git_common_dir()?;
42    let common = canonicalize_clean(&common);
43    common
44        .parent()
45        .map(|p| p.to_path_buf())
46        .ok_or_else(|| GwError::Other("Could not determine main repository root".to_string()))
47}
48
49/// Resolve the pool metadata directory ({git_common_dir}/worktree-pool/)
50fn pool_dir() -> Result<PathBuf> {
51    let common = git::git_common_dir()?;
52    Ok(common.join(POOL_META_DIR))
53}
54
55/// Resolve the acquired markers directory
56fn acquired_dir() -> Result<PathBuf> {
57    Ok(pool_dir()?.join(ACQUIRED_DIR))
58}
59
60/// Resolve the worktrees directory ({main_repo_root}/.worktrees/)
61fn worktrees_dir() -> Result<PathBuf> {
62    let root = main_repo_root()?;
63    Ok(root.join(POOL_WORKTREES_DIR))
64}
65
66/// Run the setup hook if it exists
67fn run_setup_hook(repo_root: &Path, worktree_path: &str, verbose: bool) -> Result<()> {
68    let hook = repo_root.join(SETUP_HOOK);
69    if !hook.exists() {
70        return Ok(());
71    }
72
73    if verbose {
74        output::action(&format!("Running setup hook: {}", hook.display()));
75    }
76
77    let status = std::process::Command::new(&hook)
78        .arg(worktree_path)
79        .current_dir(worktree_path)
80        .status()?;
81
82    if !status.success() {
83        return Err(GwError::Other(format!(
84            "Setup hook failed with exit code: {}",
85            status.code().unwrap_or(-1)
86        )));
87    }
88    Ok(())
89}
90
91/// Get the current branch of a worktree by running git in that directory
92fn worktree_current_branch(path: &Path) -> String {
93    let path_str = path.to_string_lossy();
94    std::process::Command::new("git")
95        .args(["rev-parse", "--abbrev-ref", "HEAD"])
96        .current_dir(&*path_str)
97        .output()
98        .ok()
99        .filter(|o| o.status.success())
100        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
101        .unwrap_or_else(|| "???".to_string())
102}
103
104/// Get the owner name for acquire markers (current worktree directory name)
105fn current_owner_name() -> String {
106    git::current_dir_name().unwrap_or_else(|_| "unknown".to_string())
107}
108
109/// Check if the current directory is inside a pool worktree.
110/// Returns the pool worktree name if so.
111pub fn detect_pool_worktree() -> Option<String> {
112    let cwd = std::env::current_dir().ok()?;
113    let wt_dir = worktrees_dir().ok()?;
114
115    if !cwd.starts_with(&wt_dir) {
116        return None;
117    }
118
119    // Get the pool worktree directory name (first component after .worktrees/)
120    cwd.strip_prefix(&wt_dir)
121        .ok()
122        .and_then(|rel| rel.components().next())
123        .and_then(|c| c.as_os_str().to_str())
124        .filter(|name| name.starts_with("pool-"))
125        .map(String::from)
126}
127
128/// Release a pool worktree: clean files, remove marker, run setup hook.
129/// Called from `gw cleanup` when inside a pool worktree.
130pub fn pool_release_after_cleanup(pool_name: &str, verbose: bool) -> Result<()> {
131    let acquired_dir = acquired_dir()?;
132    let repo_root = main_repo_root()?;
133    let wt_dir = worktrees_dir()?;
134    let wt_path = wt_dir.join(pool_name);
135    let wt_path_str = wt_path.to_string_lossy().to_string();
136
137    output::info("Releasing pool worktree...");
138
139    // Clean untracked files
140    git::git_run_in_dir(&wt_path_str, &["clean", "-fd"], verbose)?;
141
142    // Run setup hook (if it fails, keep marker and warn)
143    if let Err(e) = run_setup_hook(&repo_root, &wt_path_str, verbose) {
144        output::warn(&format!(
145            "Setup hook failed during release: {e}. Worktree remains acquired."
146        ));
147        return Ok(());
148    }
149
150    // Remove acquired marker
151    let marker = acquired_dir.join(pool_name);
152    if marker.exists() {
153        std::fs::remove_file(&marker)?;
154    }
155
156    output::success("Pool worktree released and available for reuse");
157    Ok(())
158}
159
160// --- Pool commands ---
161
162/// `gw worktree pool warm <n>`
163pub fn warm(count: usize, verbose: bool) -> Result<()> {
164    if !git::is_git_repo() {
165        return Err(GwError::NotAGitRepository);
166    }
167
168    let pool_dir = pool_dir()?;
169    let wt_dir = worktrees_dir()?;
170    let acquired_dir = acquired_dir()?;
171    let repo_root = main_repo_root()?;
172
173    println!();
174    output::info(&format!(
175        "Warming worktree pool to {} available",
176        output::bold(&count.to_string())
177    ));
178
179    // Acquire lock and scan filesystem
180    let _lock = PoolLock::acquire(&pool_dir)?;
181    let mut state = PoolState::scan(&wt_dir, &acquired_dir)?;
182
183    let available = state.count_by_status(&WorktreeStatus::Available);
184    let acquired = state.count_by_status(&WorktreeStatus::Acquired);
185    let total = state.entries.len();
186    if available >= count {
187        output::success(&format!(
188            "Pool already has {available} available ({acquired} acquired, {total} total), nothing to do"
189        ));
190        return Ok(());
191    }
192
193    let to_create = count - available;
194
195    // Fetch once
196    output::info("Fetching from origin...");
197    git::fetch_prune(verbose)?;
198    output::success("Fetched");
199
200    let default_remote = git::get_default_remote_branch()?;
201
202    // Create worktrees dir
203    std::fs::create_dir_all(&wt_dir)?;
204
205    let mut created = 0;
206    for i in 0..to_create {
207        let name = state.next_name();
208        let abs_path = canonicalize_clean(&wt_dir).join(&name);
209        let abs_path_str = abs_path.to_string_lossy().to_string();
210        // Branch name = directory name (gw convention: dir name = home branch)
211        let branch = name.clone();
212
213        output::info(&format!(
214            "[{}/{}] Creating {}...",
215            i + 1,
216            to_create,
217            output::bold(&name)
218        ));
219
220        // Create the worktree
221        if let Err(e) = git::worktree_add(&abs_path_str, &branch, &default_remote, verbose) {
222            output::warn(&format!("Failed to create {name}: {e}"));
223            continue;
224        }
225
226        // Run setup hook
227        if let Err(e) = run_setup_hook(&repo_root, &abs_path_str, verbose) {
228            output::warn(&format!(
229                "Setup hook failed for {name}: {e}. Removing worktree."
230            ));
231            let _ = git::worktree_remove(&abs_path_str, verbose);
232            let _ = git::force_delete_branch(&branch, verbose);
233            continue;
234        }
235
236        // Track in-memory for next_name() to work correctly
237        state.entries.push(PoolEntry {
238            name: name.clone(),
239            path: abs_path,
240            branch,
241            status: WorktreeStatus::Available,
242            owner: None,
243        });
244        created += 1;
245
246        output::success(&format!("[{}/{}] Created {}", i + 1, to_create, name));
247    }
248
249    // Re-scan for accurate final counts
250    let final_state = PoolState::scan(&wt_dir, &acquired_dir)?;
251    let total = final_state.entries.len();
252    let available = final_state.count_by_status(&WorktreeStatus::Available);
253
254    println!();
255    output::success(&format!(
256        "Pool warmed: {created} created, {available} available, {total} total"
257    ));
258
259    Ok(())
260}
261
262/// `gw worktree pool acquire`
263pub fn acquire(_verbose: bool) -> Result<()> {
264    if !git::is_git_repo() {
265        return Err(GwError::NotAGitRepository);
266    }
267
268    let pool_dir = pool_dir()?;
269    let wt_dir = worktrees_dir()?;
270    let acquired_dir = acquired_dir()?;
271
272    if !wt_dir.exists() {
273        return Err(GwError::PoolNotInitialized);
274    }
275
276    let _lock = PoolLock::acquire(&pool_dir)?;
277
278    // Ensure acquired dir exists
279    std::fs::create_dir_all(&acquired_dir)?;
280
281    let state = PoolState::scan(&wt_dir, &acquired_dir)?;
282
283    if state.entries.is_empty() {
284        return Err(GwError::PoolNotInitialized);
285    }
286
287    let entry = state.find_available().ok_or(GwError::PoolExhausted)?;
288
289    // Create marker file with owner name
290    let owner = current_owner_name();
291    std::fs::write(acquired_dir.join(&entry.name), &owner)?;
292
293    let path = entry.path.to_string_lossy().to_string();
294    let name = entry.name.clone();
295
296    let remaining = state.count_by_status(&WorktreeStatus::Available) - 1;
297    eprintln!(
298        "\x1b[0;32m\u{2713}\x1b[0m Acquired {} (owner: {}, {} remaining)",
299        name, owner, remaining,
300    );
301
302    // Print ONLY the path to stdout for `path=$(gw worktree pool acquire)`
303    println!("{path}");
304
305    Ok(())
306}
307
308/// `gw worktree pool status`
309pub fn status(verbose: bool) -> Result<()> {
310    if !git::is_git_repo() {
311        return Err(GwError::NotAGitRepository);
312    }
313
314    let wt_dir = worktrees_dir()?;
315    let acquired_dir = acquired_dir()?;
316
317    if !wt_dir.exists() {
318        return Err(GwError::PoolNotInitialized);
319    }
320
321    // Read-only — no lock needed
322    let state = PoolState::scan(&wt_dir, &acquired_dir)?;
323
324    if state.entries.is_empty() {
325        return Err(GwError::PoolNotInitialized);
326    }
327
328    let me = current_owner_name();
329    let available = state.count_by_status(&WorktreeStatus::Available);
330    let acquired = state.count_by_status(&WorktreeStatus::Acquired);
331    let mine: Vec<&PoolEntry> = state
332        .entries
333        .iter()
334        .filter(|e| e.owner.as_deref() == Some(&me))
335        .collect();
336    let total = state.entries.len();
337
338    println!();
339    output::info(&format!(
340        "Pool: {} available, {} acquired ({} by you), {} total",
341        output::bold(&available.to_string()),
342        output::bold(&acquired.to_string()),
343        output::bold(&mine.len().to_string()),
344        output::bold(&total.to_string()),
345    ));
346
347    if !mine.is_empty() {
348        println!();
349        let header = format!("{:<12} {:<24}", "NAME", "BRANCH");
350        println!("{header}");
351        println!("{}", "-".repeat(36));
352
353        for entry in &mine {
354            let branch = worktree_current_branch(&entry.path);
355            println!("{:<12} {}", entry.name, branch);
356        }
357    }
358
359    if verbose {
360        // --verbose: show all entries
361        println!();
362        output::info("All entries:");
363        println!();
364        let header = format!("{:<12} {:<12} {:<24} OWNER", "NAME", "STATUS", "BRANCH");
365        println!("{header}");
366        println!("{}", "-".repeat(72));
367
368        for entry in &state.entries {
369            let branch = if entry.status == WorktreeStatus::Acquired {
370                worktree_current_branch(&entry.path)
371            } else {
372                entry.branch.clone()
373            };
374            let owner = entry.owner.as_deref().unwrap_or("-");
375            println!(
376                "{:<12} {:<12} {:<24} {}",
377                entry.name, entry.status, branch, owner
378            );
379        }
380    }
381
382    // Show next action
383    let next = state.next_action();
384    println!();
385    display_pool_next_action(&next);
386
387    println!();
388    Ok(())
389}
390
391fn display_pool_next_action(action: &PoolNextAction) {
392    match action {
393        PoolNextAction::WarmPool => {
394            output::action("Next: warm the pool");
395            println!("  gw worktree pool warm <count>");
396        }
397        PoolNextAction::Ready { available } => {
398            output::action(&format!("Ready: {} worktree(s) available", available));
399            println!("  gw worktree pool acquire");
400        }
401        PoolNextAction::Exhausted { acquired } => {
402            output::action(&format!(
403                "All {} worktree(s) acquired. Warm more or wait.",
404                acquired
405            ));
406            println!("  gw worktree pool warm <count>");
407        }
408        PoolNextAction::AllIdle { available } => {
409            output::action(&format!(
410                "All {} worktree(s) idle. Acquire or drain.",
411                available
412            ));
413            println!("  gw worktree pool acquire");
414            println!("  gw worktree pool drain");
415        }
416    }
417}
418
419/// `gw worktree pool drain [--force]`
420pub fn drain(force: bool, verbose: bool) -> Result<()> {
421    if !git::is_git_repo() {
422        return Err(GwError::NotAGitRepository);
423    }
424
425    let pool_dir = pool_dir()?;
426    let wt_dir = worktrees_dir()?;
427    let acquired_dir = acquired_dir()?;
428    // Resolve all paths upfront — the cwd might be inside a pool worktree
429    // that we're about to delete.
430    let repo_root = main_repo_root()?;
431    let repo_root_str = repo_root.to_string_lossy().to_string();
432
433    if !wt_dir.exists() {
434        return Err(GwError::PoolNotInitialized);
435    }
436
437    println!();
438    output::info("Draining worktree pool...");
439
440    let _lock = PoolLock::acquire(&pool_dir)?;
441    let state = PoolState::scan(&wt_dir, &acquired_dir)?;
442
443    if state.entries.is_empty() {
444        return Err(GwError::PoolNotInitialized);
445    }
446
447    // Check for acquired worktrees
448    let acquired = state.count_by_status(&WorktreeStatus::Acquired);
449    if acquired > 0 && !force {
450        return Err(GwError::PoolHasAcquiredWorktrees(acquired));
451    }
452
453    let total = state.entries.len();
454
455    for (i, entry) in state.entries.iter().enumerate() {
456        output::info(&format!(
457            "[{}/{}] Removing {}...",
458            i + 1,
459            total,
460            output::bold(&entry.name)
461        ));
462
463        let path_str = entry.path.to_string_lossy().to_string();
464
465        // Remove the worktree (run from repo_root so it works even if cwd is deleted)
466        if let Err(e) = git::git_run_in_dir(
467            &repo_root_str,
468            &["worktree", "remove", "--force", &path_str],
469            verbose,
470        ) {
471            output::warn(&format!("Failed to remove worktree {}: {e}", entry.name));
472            let _ = std::fs::remove_dir_all(&entry.path);
473        }
474
475        // Delete the pool branch
476        if let Err(e) =
477            git::git_run_in_dir(&repo_root_str, &["branch", "-D", &entry.branch], verbose)
478        {
479            output::warn(&format!("Failed to delete branch {}: {e}", entry.branch));
480        }
481
482        // Remove acquired marker if present
483        let marker = acquired_dir.join(&entry.name);
484        let _ = std::fs::remove_file(&marker);
485
486        output::success(&format!("[{}/{}] Removed {}", i + 1, total, entry.name));
487    }
488
489    // Clean up pool metadata
490    if acquired_dir.exists() {
491        let _ = std::fs::remove_dir_all(&acquired_dir);
492    }
493    let _ = std::fs::remove_file(pool_dir.join("pool.lock"));
494
495    // Prune worktree references
496    git::git_run_in_dir(&repo_root_str, &["worktree", "prune"], verbose)?;
497
498    // Remove empty directories
499    if wt_dir.exists() {
500        let _ = std::fs::remove_dir(&wt_dir);
501    }
502    drop(_lock);
503    let _ = std::fs::remove_dir(&pool_dir);
504
505    println!();
506    output::success(&format!("Drained {total} worktree(s) from pool"));
507
508    Ok(())
509}