Skip to main content

git_workflow/commands/
worktree_pool.rs

1//! `gw worktree pool` commands — Worktree pool management
2
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::error::{GwError, Result};
7use crate::git;
8use crate::output;
9use crate::pool::{Inventory, PoolEntry, PoolLock, WorktreeStatus};
10
11/// Directory name under git_common_dir for pool metadata
12const POOL_META_DIR: &str = "worktree-pool";
13
14/// Canonicalize a path, stripping the `\\?\` prefix on Windows so that
15/// external tools (like git) can consume the path without issues.
16fn canonicalize_clean(path: &Path) -> PathBuf {
17    let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
18    #[cfg(target_os = "windows")]
19    {
20        let s = canonical.to_string_lossy();
21        if let Some(stripped) = s.strip_prefix(r"\\?\") {
22            return PathBuf::from(stripped);
23        }
24    }
25    canonical
26}
27
28/// Directory name under repo root for pool worktrees
29const POOL_WORKTREES_DIR: &str = ".worktrees";
30
31/// Setup hook path relative to repo root
32const SETUP_HOOK: &str = ".gw/setup";
33
34/// Get the main repository root (parent of .git), even from inside a worktree.
35/// Unlike `git::repo_root()` which returns the worktree root when called from
36/// inside a worktree, this always returns the main repo root.
37fn main_repo_root() -> Result<PathBuf> {
38    let common = git::git_common_dir()?;
39    // git_common_dir may return a relative path like ".git", so canonicalize
40    // to get an absolute path before taking the parent.
41    let common = canonicalize_clean(&common);
42    common
43        .parent()
44        .map(|p| p.to_path_buf())
45        .ok_or_else(|| GwError::Other("Could not determine main repository root".to_string()))
46}
47
48/// Resolve the pool metadata directory ({git_common_dir}/worktree-pool/)
49fn pool_dir() -> Result<PathBuf> {
50    let common = git::git_common_dir()?;
51    Ok(common.join(POOL_META_DIR))
52}
53
54/// Resolve the inventory file path
55fn inventory_path() -> Result<PathBuf> {
56    Ok(pool_dir()?.join("inventory.json"))
57}
58
59/// Resolve the worktrees directory ({main_repo_root}/.worktrees/)
60fn worktrees_dir() -> Result<PathBuf> {
61    let root = main_repo_root()?;
62    Ok(root.join(POOL_WORKTREES_DIR))
63}
64
65/// Get current unix timestamp
66fn now_unix() -> u64 {
67    SystemTime::now()
68        .duration_since(UNIX_EPOCH)
69        .unwrap_or_default()
70        .as_secs()
71}
72
73/// Run the setup hook if it exists
74fn run_setup_hook(repo_root: &Path, worktree_path: &str, verbose: bool) -> Result<()> {
75    let hook = repo_root.join(SETUP_HOOK);
76    if !hook.exists() {
77        return Ok(());
78    }
79
80    if verbose {
81        output::action(&format!("Running setup hook: {}", hook.display()));
82    }
83
84    let status = std::process::Command::new(&hook)
85        .arg(worktree_path)
86        .current_dir(worktree_path)
87        .status()?;
88
89    if !status.success() {
90        return Err(GwError::Other(format!(
91            "Setup hook failed with exit code: {}",
92            status.code().unwrap_or(-1)
93        )));
94    }
95    Ok(())
96}
97
98/// `gw worktree pool warm <n>`
99pub fn warm(count: usize, verbose: bool) -> Result<()> {
100    if !git::is_git_repo() {
101        return Err(GwError::NotAGitRepository);
102    }
103
104    let pool_dir = pool_dir()?;
105    let inv_path = inventory_path()?;
106    let wt_dir = worktrees_dir()?;
107    let repo_root = main_repo_root()?;
108
109    println!();
110    output::info(&format!(
111        "Warming worktree pool to {} available",
112        output::bold(&count.to_string())
113    ));
114
115    // Acquire lock and load inventory
116    let _lock = PoolLock::acquire(&pool_dir)?;
117    let mut inventory = Inventory::load(&inv_path)?;
118
119    let available = inventory.count_by_status(&WorktreeStatus::Available);
120    let acquired = inventory.count_by_status(&WorktreeStatus::Acquired);
121    let total = inventory.worktrees.len();
122    if available >= count {
123        output::success(&format!(
124            "Pool already has {available} available ({acquired} acquired, {total} total), nothing to do"
125        ));
126        return Ok(());
127    }
128
129    let to_create = count - available;
130
131    // Fetch once
132    output::info("Fetching from origin...");
133    git::fetch_prune(verbose)?;
134    output::success("Fetched");
135
136    let default_remote = git::get_default_remote_branch()?;
137
138    // Create worktrees dir
139    std::fs::create_dir_all(&wt_dir)?;
140
141    let mut created = 0;
142    for i in 0..to_create {
143        let name = inventory.next_name();
144        let abs_path = canonicalize_clean(&wt_dir).join(&name);
145        let abs_path_str = abs_path.to_string_lossy().to_string();
146        let branch = format!("pool/{name}");
147
148        output::info(&format!(
149            "[{}/{}] Creating {}...",
150            i + 1,
151            to_create,
152            output::bold(&name)
153        ));
154
155        // Create the worktree
156        if let Err(e) = git::worktree_add(&abs_path_str, &branch, &default_remote, verbose) {
157            output::warn(&format!("Failed to create {name}: {e}"));
158            continue;
159        }
160
161        // Run setup hook
162        if let Err(e) = run_setup_hook(&repo_root, &abs_path_str, verbose) {
163            output::warn(&format!(
164                "Setup hook failed for {name}: {e}. Removing worktree."
165            ));
166            let _ = git::worktree_remove(&abs_path_str, verbose);
167            let _ = git::force_delete_branch(&branch, verbose);
168            continue;
169        }
170
171        inventory.worktrees.push(PoolEntry {
172            name: name.clone(),
173            path: abs_path_str,
174            branch,
175            status: WorktreeStatus::Available,
176            created_at: now_unix(),
177            acquired_at: None,
178            acquired_by: None,
179        });
180        created += 1;
181
182        output::success(&format!("[{}/{}] Created {}", i + 1, to_create, name));
183    }
184
185    inventory.save(&inv_path)?;
186
187    let total = inventory.worktrees.len();
188    let available = inventory.count_by_status(&WorktreeStatus::Available);
189
190    println!();
191    output::success(&format!(
192        "Pool warmed: {created} created, {available} available, {total} total"
193    ));
194
195    Ok(())
196}
197
198/// `gw worktree pool acquire`
199pub fn acquire(_verbose: bool) -> Result<()> {
200    if !git::is_git_repo() {
201        return Err(GwError::NotAGitRepository);
202    }
203
204    let pool_dir = pool_dir()?;
205    let inv_path = inventory_path()?;
206
207    if !inv_path.exists() {
208        return Err(GwError::PoolNotInitialized);
209    }
210
211    let _lock = PoolLock::acquire(&pool_dir)?;
212    let mut inventory = Inventory::load(&inv_path)?;
213
214    let idx = inventory.find_available().ok_or(GwError::PoolExhausted)?;
215
216    let entry = &mut inventory.worktrees[idx];
217    entry.status = WorktreeStatus::Acquired;
218    entry.acquired_at = Some(now_unix());
219    entry.acquired_by = Some(std::process::id());
220
221    let path = entry.path.clone();
222    let name = entry.name.clone();
223
224    inventory.save(&inv_path)?;
225
226    // Always print status to stderr so stdout stays clean for scripting
227    let remaining = inventory.count_by_status(&WorktreeStatus::Available);
228    eprintln!(
229        "\x1b[0;32m\u{2713}\x1b[0m Acquired {} (PID {}, {} remaining)",
230        name,
231        std::process::id(),
232        remaining,
233    );
234
235    // Print ONLY the path to stdout for `path=$(gw worktree pool acquire)`
236    println!("{path}");
237
238    Ok(())
239}
240
241/// `gw worktree pool release [name|path]`
242pub fn release(identifier: Option<&str>, verbose: bool) -> Result<()> {
243    if !git::is_git_repo() {
244        return Err(GwError::NotAGitRepository);
245    }
246
247    let pool_dir = pool_dir()?;
248    let inv_path = inventory_path()?;
249    let repo_root = main_repo_root()?;
250
251    if !inv_path.exists() {
252        return Err(GwError::PoolNotInitialized);
253    }
254
255    // Auto-detect from cwd if no identifier given
256    let resolved = match identifier {
257        Some(id) => id.to_string(),
258        None => std::env::current_dir()
259            .map_err(GwError::Io)?
260            .to_string_lossy()
261            .to_string(),
262    };
263
264    println!();
265    output::info(&format!("Releasing worktree: {}", output::bold(&resolved)));
266
267    let _lock = PoolLock::acquire(&pool_dir)?;
268    let mut inventory = Inventory::load(&inv_path)?;
269
270    let idx = inventory
271        .find_by_name_or_path(&resolved)
272        .ok_or_else(|| GwError::PoolWorktreeNotFound(resolved.clone()))?;
273
274    let entry = &inventory.worktrees[idx];
275    let wt_path = entry.path.clone();
276    let name = entry.name.clone();
277
278    let default_remote = git::get_default_remote_branch()?;
279
280    // Fetch latest before resetting so the worktree gets a fresh base
281    output::info("Fetching from origin...");
282    git::git_run_in_dir(&wt_path, &["fetch", "--prune", "--quiet"], verbose)?;
283    output::success("Fetched");
284
285    // Reset worktree to clean state
286    output::info("Resetting worktree...");
287    git::git_run_in_dir(&wt_path, &["reset", "--hard", &default_remote], verbose)?;
288    git::git_run_in_dir(&wt_path, &["clean", "-fd"], verbose)?;
289    output::success("Reset to clean state");
290
291    // Re-run setup hook
292    if let Err(e) = run_setup_hook(&repo_root, &wt_path, verbose) {
293        output::warn(&format!("Setup hook failed during release: {e}"));
294    }
295
296    let entry = &mut inventory.worktrees[idx];
297    entry.status = WorktreeStatus::Available;
298    entry.acquired_at = None;
299    entry.acquired_by = None;
300
301    inventory.save(&inv_path)?;
302
303    output::success(&format!("Released {}", output::bold(&name)));
304
305    Ok(())
306}
307
308/// `gw worktree pool status`
309pub fn status() -> Result<()> {
310    if !git::is_git_repo() {
311        return Err(GwError::NotAGitRepository);
312    }
313
314    let inv_path = inventory_path()?;
315
316    if !inv_path.exists() {
317        return Err(GwError::PoolNotInitialized);
318    }
319
320    // Read-only — no lock needed
321    let inventory = Inventory::load(&inv_path)?;
322
323    let available = inventory.count_by_status(&WorktreeStatus::Available);
324    let acquired = inventory.count_by_status(&WorktreeStatus::Acquired);
325    let total = inventory.worktrees.len();
326
327    println!();
328    output::info(&format!(
329        "Pool: {} available, {} acquired, {} total",
330        output::bold(&available.to_string()),
331        output::bold(&acquired.to_string()),
332        output::bold(&total.to_string()),
333    ));
334    println!();
335
336    // Table header
337    let header = format!("{:<12} {:<12} {:<8} PATH", "NAME", "STATUS", "PID");
338    println!("{header}");
339    println!("{}", "-".repeat(72));
340
341    for entry in &inventory.worktrees {
342        let pid = entry
343            .acquired_by
344            .map(|p| p.to_string())
345            .unwrap_or_else(|| "-".to_string());
346        println!(
347            "{:<12} {:<12} {:<8} {}",
348            entry.name, entry.status, pid, entry.path
349        );
350    }
351
352    println!();
353    Ok(())
354}
355
356/// `gw worktree pool drain [--force]`
357pub fn drain(force: bool, verbose: bool) -> Result<()> {
358    if !git::is_git_repo() {
359        return Err(GwError::NotAGitRepository);
360    }
361
362    let pool_dir = pool_dir()?;
363    let inv_path = inventory_path()?;
364    // Resolve all paths upfront — the cwd might be inside a pool worktree
365    // that we're about to delete, so all git/fs calls after removal must
366    // not depend on cwd being valid.
367    let repo_root = main_repo_root()?;
368    let repo_root_str = repo_root.to_string_lossy().to_string();
369    let wt_dir = worktrees_dir()?;
370
371    if !inv_path.exists() {
372        return Err(GwError::PoolNotInitialized);
373    }
374
375    println!();
376    output::info("Draining worktree pool...");
377
378    let _lock = PoolLock::acquire(&pool_dir)?;
379    let inventory = Inventory::load(&inv_path)?;
380
381    // Check for acquired worktrees
382    let acquired = inventory.count_by_status(&WorktreeStatus::Acquired);
383    if acquired > 0 && !force {
384        return Err(GwError::PoolHasAcquiredWorktrees(acquired));
385    }
386
387    let total = inventory.worktrees.len();
388
389    for (i, entry) in inventory.worktrees.iter().enumerate() {
390        output::info(&format!(
391            "[{}/{}] Removing {}...",
392            i + 1,
393            total,
394            output::bold(&entry.name)
395        ));
396
397        // Remove the worktree (run from repo_root so it works even if cwd is deleted)
398        if let Err(e) = git::git_run_in_dir(
399            &repo_root_str,
400            &["worktree", "remove", "--force", &entry.path],
401            verbose,
402        ) {
403            output::warn(&format!("Failed to remove worktree {}: {e}", entry.name));
404            let _ = std::fs::remove_dir_all(&entry.path);
405        }
406
407        // Delete the pool branch
408        if let Err(e) =
409            git::git_run_in_dir(&repo_root_str, &["branch", "-D", &entry.branch], verbose)
410        {
411            output::warn(&format!("Failed to delete branch {}: {e}", entry.branch));
412        }
413
414        output::success(&format!("[{}/{}] Removed {}", i + 1, total, entry.name));
415    }
416
417    // Clean up pool metadata
418    let _ = std::fs::remove_file(&inv_path);
419    let _ = std::fs::remove_file(pool_dir.join("pool.lock"));
420
421    // Prune worktree references
422    git::git_run_in_dir(&repo_root_str, &["worktree", "prune"], verbose)?;
423
424    // Remove empty directories
425    if wt_dir.exists() {
426        let _ = std::fs::remove_dir(&wt_dir);
427    }
428    drop(_lock);
429    let _ = std::fs::remove_dir(&pool_dir);
430
431    println!();
432    output::success(&format!("Drained {total} worktree(s) from pool"));
433
434    Ok(())
435}