1use 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
14const POOL_META_DIR: &str = "pool";
16
17const POOL_WORKTREES_DIR: &str = ".worktrees";
19
20const SETUP_HOOK: &str = ".gw/setup";
22
23const ACQUIRED_DIR: &str = "acquired";
25
26fn 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
40fn leader_root() -> Result<PathBuf> {
42 let root = git::worktree_root()?;
43 Ok(canonicalize_clean(&root))
44}
45
46fn 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 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
65fn pool_prefix() -> Result<String> {
67 Ok(format!("{}-pool-", leader_name()?))
68}
69
70fn 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
80fn 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
87fn acquired_dir() -> Result<PathBuf> {
89 Ok(pool_dir()?.join(ACQUIRED_DIR))
90}
91
92fn worktrees_dir() -> Result<PathBuf> {
94 let root = leader_root()?;
95 Ok(root.join(POOL_WORKTREES_DIR))
96}
97
98fn 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
123fn 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
136fn 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
158fn 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
171pub 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 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_excluded()?;
209
210 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 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 let branch = name.clone();
227
228 output::info(&format!(
229 "[{}/{}] Creating {}...",
230 i + 1,
231 to_create,
232 output::bold(&name)
233 ));
234
235 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 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 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 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
277pub 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 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 let owner = leader_name()?;
307 std::fs::write(acquired_dir.join(&entry.name), &owner)?;
308
309 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 println!("{path}");
327
328 Ok(())
329}
330
331pub 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 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
400pub 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 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 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 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
512pub 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 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 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 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 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 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 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 git::git_run_in_dir(&leader_str, &["worktree", "prune"], verbose)?;
590
591 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}