use std::path::{Path, PathBuf};
use crate::error::{GwError, Result};
use crate::git;
use crate::output;
use crate::pool::{PoolEntry, PoolLock, PoolNextAction, PoolState, WorktreeStatus};
const POOL_META_DIR: &str = "pool";
const POOL_WORKTREES_DIR: &str = ".worktrees";
const SETUP_HOOK: &str = ".gw/setup";
const ACQUIRED_DIR: &str = "acquired";
fn canonicalize_clean(path: &Path) -> PathBuf {
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
#[cfg(target_os = "windows")]
{
let s = canonical.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") {
return PathBuf::from(stripped);
}
}
canonical
}
fn leader_root() -> Result<PathBuf> {
let root = git::worktree_root()?;
Ok(canonicalize_clean(&root))
}
fn leader_name() -> Result<String> {
let root = leader_root()?;
let raw = root
.file_name()
.and_then(|s| s.to_str())
.map(String::from)
.ok_or_else(|| GwError::Other("Could not determine leader name".to_string()))?;
let sanitized = raw.trim_start_matches('.');
if sanitized.is_empty() {
return Err(GwError::Other(format!(
"Leader directory name is not valid for branch naming: {raw}"
)));
}
Ok(sanitized.to_string())
}
fn pool_prefix() -> Result<String> {
Ok(format!("{}-pool-", leader_name()?))
}
fn main_repo_root() -> Result<PathBuf> {
let common = git::git_common_dir()?;
let common = canonicalize_clean(&common);
common
.parent()
.map(|p| p.to_path_buf())
.ok_or_else(|| GwError::Other("Could not determine main repository root".to_string()))
}
fn pool_dir() -> Result<PathBuf> {
let git_dir = git::git_dir()?;
let git_dir = canonicalize_clean(&git_dir);
Ok(git_dir.join(POOL_META_DIR))
}
fn acquired_dir() -> Result<PathBuf> {
Ok(pool_dir()?.join(ACQUIRED_DIR))
}
fn worktrees_dir() -> Result<PathBuf> {
let root = leader_root()?;
Ok(root.join(POOL_WORKTREES_DIR))
}
fn run_setup_hook(repo_root: &Path, worktree_path: &str, verbose: bool) -> Result<()> {
let hook = repo_root.join(SETUP_HOOK);
if !hook.exists() {
return Ok(());
}
if verbose {
output::action(&format!("Running setup hook: {}", hook.display()));
}
let status = std::process::Command::new(&hook)
.arg(worktree_path)
.current_dir(worktree_path)
.status()?;
if !status.success() {
return Err(GwError::Other(format!(
"Setup hook failed with exit code: {}",
status.code().unwrap_or(-1)
)));
}
Ok(())
}
fn worktree_current_branch(path: &Path) -> String {
let path_str = path.to_string_lossy();
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&*path_str)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "???".to_string())
}
fn ensure_excluded() -> Result<()> {
let common = git::git_common_dir()?;
let exclude_path = common.join("info").join("exclude");
let entry = ".worktrees/";
if exclude_path.exists() {
let content = std::fs::read_to_string(&exclude_path)?;
if content.lines().any(|line| line.trim() == entry) {
return Ok(());
}
let prefix = if content.ends_with('\n') { "" } else { "\n" };
std::fs::write(&exclude_path, format!("{content}{prefix}{entry}\n"))?;
} else {
std::fs::create_dir_all(common.join("info"))?;
std::fs::write(&exclude_path, format!("{entry}\n"))?;
}
Ok(())
}
fn release_one(entry: &PoolEntry, acquired_dir: &Path) -> Result<()> {
let marker = acquired_dir.join(&entry.name);
if marker.exists() {
std::fs::remove_file(&marker)?;
}
output::success(&format!("{} released", entry.name));
Ok(())
}
pub fn warm(count: usize, verbose: bool) -> Result<()> {
if !git::is_git_repo() {
return Err(GwError::NotAGitRepository);
}
let pool_dir = pool_dir()?;
let wt_dir = worktrees_dir()?;
let acquired_dir = acquired_dir()?;
let repo_root = main_repo_root()?;
let prefix = pool_prefix()?;
println!();
output::info(&format!(
"Warming worktree pool to {} available",
output::bold(&count.to_string())
));
let _lock = PoolLock::acquire(&pool_dir)?;
let mut state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
let available = state.count_by_status(&WorktreeStatus::Available);
let acquired = state.count_by_status(&WorktreeStatus::Acquired);
let total = state.entries.len();
if available >= count {
output::success(&format!(
"Pool already has {available} available ({acquired} acquired, {total} total), nothing to do"
));
return Ok(());
}
let to_create = count - available;
ensure_excluded()?;
output::info("Fetching from origin...");
git::fetch_prune(verbose)?;
output::success("Fetched");
let default_remote = git::get_default_remote_branch()?;
std::fs::create_dir_all(&wt_dir)?;
let mut created = 0;
for i in 0..to_create {
let name = state.next_name(&prefix);
let abs_path = canonicalize_clean(&wt_dir).join(&name);
let abs_path_str = abs_path.to_string_lossy().to_string();
let branch = name.clone();
output::info(&format!(
"[{}/{}] Creating {}...",
i + 1,
to_create,
output::bold(&name)
));
if let Err(e) = git::worktree_add(&abs_path_str, &branch, &default_remote, verbose) {
output::warn(&format!("Failed to create {name}: {e}"));
continue;
}
if let Err(e) = run_setup_hook(&repo_root, &abs_path_str, verbose) {
output::warn(&format!(
"Setup hook failed for {name}: {e}. Removing worktree."
));
let _ = git::worktree_remove(&abs_path_str, verbose);
let _ = git::force_delete_branch(&branch, verbose);
continue;
}
state.entries.push(PoolEntry {
name: name.clone(),
path: abs_path,
branch,
status: WorktreeStatus::Available,
owner: None,
});
created += 1;
output::success(&format!("[{}/{}] Created {}", i + 1, to_create, name));
}
let final_state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
let total = final_state.entries.len();
let available = final_state.count_by_status(&WorktreeStatus::Available);
println!();
output::success(&format!(
"Pool warmed: {created} created, {available} available, {total} total"
));
Ok(())
}
pub fn acquire(verbose: bool) -> Result<()> {
if !git::is_git_repo() {
return Err(GwError::NotAGitRepository);
}
let pool_dir = pool_dir()?;
let wt_dir = worktrees_dir()?;
let acquired_dir = acquired_dir()?;
let prefix = pool_prefix()?;
if !wt_dir.exists() {
return Err(GwError::PoolNotInitialized);
}
let _lock = PoolLock::acquire(&pool_dir)?;
std::fs::create_dir_all(&acquired_dir)?;
let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
if state.entries.is_empty() {
return Err(GwError::PoolNotInitialized);
}
let entry = state.find_available().ok_or(GwError::PoolExhausted)?;
let owner = leader_name()?;
std::fs::write(acquired_dir.join(&entry.name), &owner)?;
let wt_path = entry.path.to_string_lossy().to_string();
git::git_run_in_dir(&wt_path, &["fetch", "--prune"], verbose)?;
let default_remote = git::get_default_remote_branch()?;
let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
git::git_run_in_dir(&wt_path, &["pull", "origin", default_branch], verbose)?;
let path = entry.path.to_string_lossy().to_string();
let name = entry.name.clone();
let remaining = state.count_by_status(&WorktreeStatus::Available) - 1;
eprintln!(
"\x1b[0;32m\u{2713}\x1b[0m Acquired {} ({} remaining)",
name, remaining,
);
println!("{path}");
Ok(())
}
pub fn release(name: Option<String>, _verbose: bool) -> Result<()> {
if !git::is_git_repo() {
return Err(GwError::NotAGitRepository);
}
let pool_dir = pool_dir()?;
let wt_dir = worktrees_dir()?;
let acquired_dir = acquired_dir()?;
let prefix = pool_prefix()?;
if !wt_dir.exists() {
return Err(GwError::PoolNotInitialized);
}
let _lock = PoolLock::acquire(&pool_dir)?;
let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
if state.entries.is_empty() {
return Err(GwError::PoolNotInitialized);
}
match name {
Some(ref n) => {
let entry = state
.find_by_name_or_path(n)
.ok_or_else(|| GwError::PoolWorktreeNotFound(n.clone()))?;
if entry.status != WorktreeStatus::Acquired {
return Err(GwError::PoolWorktreeNotAcquired(entry.name.clone()));
}
release_one(entry, &acquired_dir)?;
}
None => {
let acquired: Vec<_> = state
.entries
.iter()
.filter(|e| e.status == WorktreeStatus::Acquired)
.collect();
if acquired.is_empty() {
return Err(GwError::PoolNoneAcquired);
}
for entry in &acquired {
release_one(entry, &acquired_dir)?;
}
}
}
let final_state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
let available = final_state.count_by_status(&WorktreeStatus::Available);
let acquired_count = final_state.count_by_status(&WorktreeStatus::Acquired);
let total = final_state.entries.len();
println!();
output::success(&format!(
"Pool: {} available, {} acquired, {} total",
available, acquired_count, total
));
Ok(())
}
pub fn status(verbose: bool) -> Result<()> {
if !git::is_git_repo() {
return Err(GwError::NotAGitRepository);
}
let wt_dir = worktrees_dir()?;
let acquired_dir = acquired_dir()?;
let prefix = pool_prefix()?;
if !wt_dir.exists() {
return Err(GwError::PoolNotInitialized);
}
let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
if state.entries.is_empty() {
return Err(GwError::PoolNotInitialized);
}
let available = state.count_by_status(&WorktreeStatus::Available);
let acquired = state.count_by_status(&WorktreeStatus::Acquired);
let total = state.entries.len();
println!();
output::info(&format!(
"Pool: {} available, {} acquired, {} total",
output::bold(&available.to_string()),
output::bold(&acquired.to_string()),
output::bold(&total.to_string()),
));
if acquired > 0 {
println!();
let header = format!("{:<24} {}", "NAME", "BRANCH");
println!("{header}");
println!("{}", "-".repeat(48));
for entry in &state.entries {
if entry.status != WorktreeStatus::Acquired {
continue;
}
let branch = worktree_current_branch(&entry.path);
let branch_display = if branch == entry.name {
"(idle)".to_string()
} else {
branch
};
println!("{:<24} {}", entry.name, branch_display);
println!(" {}", entry.path.display());
}
}
if verbose {
println!();
output::info("All entries:");
println!();
let header = format!("{:<24} {:<12} {:<24}", "NAME", "STATUS", "BRANCH");
println!("{header}");
println!("{}", "-".repeat(60));
for entry in &state.entries {
let branch = if entry.status == WorktreeStatus::Acquired {
worktree_current_branch(&entry.path)
} else {
entry.branch.clone()
};
println!("{:<24} {:<12} {}", entry.name, entry.status, branch);
}
}
let next = state.next_action();
println!();
display_pool_next_action(&next);
println!();
Ok(())
}
fn display_pool_next_action(action: &PoolNextAction) {
match action {
PoolNextAction::WarmPool => {
output::action("Next: warm the pool");
println!(" gw worktree pool warm <count>");
}
PoolNextAction::Ready { available } => {
output::action(&format!("Ready: {} worktree(s) available", available));
println!(" gw worktree pool acquire");
println!(" gw worktree pool release [name]");
}
PoolNextAction::Exhausted { acquired } => {
output::action(&format!(
"All {} worktree(s) acquired. Release or warm more.",
acquired
));
println!(" gw worktree pool release [name]");
println!(" gw worktree pool warm <count>");
}
PoolNextAction::AllIdle { available } => {
output::action(&format!(
"All {} worktree(s) idle. Acquire or drain.",
available
));
println!(" gw worktree pool acquire");
println!(" gw worktree pool drain");
}
}
}
pub fn drain(force: bool, verbose: bool) -> Result<()> {
if !git::is_git_repo() {
return Err(GwError::NotAGitRepository);
}
let pool_dir = pool_dir()?;
let wt_dir = worktrees_dir()?;
let acquired_dir = acquired_dir()?;
let prefix = pool_prefix()?;
let leader = leader_root()?;
let leader_str = leader.to_string_lossy().to_string();
if !wt_dir.exists() {
return Err(GwError::PoolNotInitialized);
}
println!();
output::info("Draining worktree pool...");
let _lock = PoolLock::acquire(&pool_dir)?;
let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
if state.entries.is_empty() {
return Err(GwError::PoolNotInitialized);
}
let acquired = state.count_by_status(&WorktreeStatus::Acquired);
if acquired > 0 && !force {
return Err(GwError::PoolHasAcquiredWorktrees(acquired));
}
let total = state.entries.len();
for (i, entry) in state.entries.iter().enumerate() {
output::info(&format!(
"[{}/{}] Removing {}...",
i + 1,
total,
output::bold(&entry.name)
));
let path_str = entry.path.to_string_lossy().to_string();
if let Err(e) = git::git_run_in_dir(
&leader_str,
&["worktree", "remove", "--force", &path_str],
verbose,
) {
output::warn(&format!("Failed to remove worktree {}: {e}", entry.name));
let _ = std::fs::remove_dir_all(&entry.path);
}
if let Err(e) = git::git_run_in_dir(&leader_str, &["branch", "-D", &entry.branch], verbose)
{
output::warn(&format!("Failed to delete branch {}: {e}", entry.branch));
}
let marker = acquired_dir.join(&entry.name);
let _ = std::fs::remove_file(&marker);
output::success(&format!("[{}/{}] Removed {}", i + 1, total, entry.name));
}
if acquired_dir.exists() {
let _ = std::fs::remove_dir_all(&acquired_dir);
}
let _ = std::fs::remove_file(pool_dir.join("pool.lock"));
git::git_run_in_dir(&leader_str, &["worktree", "prune"], verbose)?;
if wt_dir.exists() {
let _ = std::fs::remove_dir(&wt_dir);
}
drop(_lock);
let _ = std::fs::remove_dir(&pool_dir);
println!();
output::success(&format!("Drained {total} worktree(s) from pool"));
Ok(())
}