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 git_capture(path: &Path, args: &[&str]) -> Option<String> {
125 std::process::Command::new("git")
126 .args(args)
127 .current_dir(path)
128 .output()
129 .ok()
130 .filter(|o| o.status.success())
131 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
132}
133
134fn worktree_current_branch(path: &Path) -> String {
136 git_capture(path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "???".to_string())
137}
138
139fn in_progress_operation(path: &Path) -> Option<String> {
142 const PROBES: &[(&str, &str)] = &[
144 ("rebase-merge", "rebase"),
145 ("rebase-apply", "rebase"),
146 ("MERGE_HEAD", "merge"),
147 ("CHERRY_PICK_HEAD", "cherry-pick"),
148 ("REVERT_HEAD", "revert"),
149 ("BISECT_LOG", "bisect"),
150 ];
151 for (git_path, label) in PROBES {
152 if let Some(resolved) = git_capture(path, &["rev-parse", "--git-path", git_path]) {
153 let candidate = PathBuf::from(&resolved);
154 let full = if candidate.is_absolute() {
155 candidate
156 } else {
157 path.join(candidate)
158 };
159 if full.exists() {
160 return Some((*label).to_string());
161 }
162 }
163 }
164 None
165}
166
167fn worktree_issues(entry: &PoolEntry) -> Vec<String> {
177 let path = entry.path.as_path();
178 let mut issues = Vec::new();
179
180 if !path.exists() {
181 issues.push("worktree directory is missing".to_string());
182 return issues;
183 }
184
185 let branch = worktree_current_branch(path);
186 if branch != entry.branch {
187 issues.push(format!(
188 "on branch '{}', expected pool home branch '{}'",
189 branch, entry.branch
190 ));
191 }
192
193 match git_capture(path, &["status", "--porcelain"]) {
194 Some(s) if !s.trim().is_empty() => {
195 issues.push("has uncommitted or untracked changes".to_string());
196 }
197 None => issues.push("could not read working tree status".to_string()),
198 _ => {}
199 }
200
201 if let Some(op) = in_progress_operation(path) {
202 issues.push(format!("a {op} is in progress"));
203 }
204
205 issues
206}
207
208fn ensure_excluded() -> Result<()> {
211 let common = git::git_common_dir()?;
212 let exclude_path = common.join("info").join("exclude");
213 let entry = ".worktrees/";
214
215 if exclude_path.exists() {
216 let content = std::fs::read_to_string(&exclude_path)?;
217 if content.lines().any(|line| line.trim() == entry) {
218 return Ok(());
219 }
220 let prefix = if content.ends_with('\n') { "" } else { "\n" };
221 std::fs::write(&exclude_path, format!("{content}{prefix}{entry}\n"))?;
222 } else {
223 std::fs::create_dir_all(common.join("info"))?;
224 std::fs::write(&exclude_path, format!("{entry}\n"))?;
225 }
226
227 Ok(())
228}
229
230fn release_one(entry: &PoolEntry, acquired_dir: &Path) -> Result<()> {
235 let marker = acquired_dir.join(&entry.name);
236 if marker.exists() {
237 std::fs::remove_file(&marker)?;
238 }
239 output::success(&format!("{} released", entry.name));
240 Ok(())
241}
242
243pub fn warm(count: usize, verbose: bool) -> Result<()> {
247 if !git::is_git_repo() {
248 return Err(GwError::NotAGitRepository);
249 }
250
251 let pool_dir = pool_dir()?;
252 let wt_dir = worktrees_dir()?;
253 let acquired_dir = acquired_dir()?;
254 let repo_root = main_repo_root()?;
255 let prefix = pool_prefix()?;
256
257 println!();
258 output::info(&format!(
259 "Warming worktree pool to {} available",
260 output::bold(&count.to_string())
261 ));
262
263 let _lock = PoolLock::acquire(&pool_dir)?;
265 let mut state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
266
267 let available = state.count_by_status(&WorktreeStatus::Available);
268 let acquired = state.count_by_status(&WorktreeStatus::Acquired);
269 let total = state.entries.len();
270 if available >= count {
271 output::success(&format!(
272 "Pool already has {available} available ({acquired} acquired, {total} total), nothing to do"
273 ));
274 return Ok(());
275 }
276
277 let to_create = count - available;
278
279 ensure_excluded()?;
281
282 output::info("Fetching from origin...");
284 git::fetch_prune(verbose)?;
285 output::success("Fetched");
286
287 let default_remote = git::get_default_remote_branch()?;
288
289 std::fs::create_dir_all(&wt_dir)?;
291
292 let mut created = 0;
293 for i in 0..to_create {
294 let name = state.next_name(&prefix);
295 let abs_path = canonicalize_clean(&wt_dir).join(&name);
296 let abs_path_str = abs_path.to_string_lossy().to_string();
297 let branch = name.clone();
299
300 output::info(&format!(
301 "[{}/{}] Creating {}...",
302 i + 1,
303 to_create,
304 output::bold(&name)
305 ));
306
307 if let Err(e) = git::worktree_add(&abs_path_str, &branch, &default_remote, verbose) {
309 output::warn(&format!("Failed to create {name}: {e}"));
310 continue;
311 }
312
313 if let Err(e) = run_setup_hook(&repo_root, &abs_path_str, verbose) {
315 output::warn(&format!(
316 "Setup hook failed for {name}: {e}. Removing worktree."
317 ));
318 let _ = git::worktree_remove(&abs_path_str, verbose);
319 let _ = git::force_delete_branch(&branch, verbose);
320 continue;
321 }
322
323 state.entries.push(PoolEntry {
325 name: name.clone(),
326 path: abs_path,
327 branch,
328 status: WorktreeStatus::Available,
329 owner: None,
330 });
331 created += 1;
332
333 output::success(&format!("[{}/{}] Created {}", i + 1, to_create, name));
334 }
335
336 let final_state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
338 let total = final_state.entries.len();
339 let available = final_state.count_by_status(&WorktreeStatus::Available);
340
341 println!();
342 output::success(&format!(
343 "Pool warmed: {created} created, {available} available, {total} total"
344 ));
345
346 Ok(())
347}
348
349pub fn acquire(verbose: bool) -> Result<()> {
351 if !git::is_git_repo() {
352 return Err(GwError::NotAGitRepository);
353 }
354
355 let pool_dir = pool_dir()?;
356 let wt_dir = worktrees_dir()?;
357 let acquired_dir = acquired_dir()?;
358 let prefix = pool_prefix()?;
359
360 if !wt_dir.exists() {
361 return Err(GwError::PoolNotInitialized);
362 }
363
364 let _lock = PoolLock::acquire(&pool_dir)?;
365
366 std::fs::create_dir_all(&acquired_dir)?;
368
369 let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
370
371 if state.entries.is_empty() {
372 return Err(GwError::PoolNotInitialized);
373 }
374
375 let available: Vec<&PoolEntry> = state
380 .entries
381 .iter()
382 .filter(|e| e.status == WorktreeStatus::Available)
383 .collect();
384 if available.is_empty() {
385 return Err(GwError::PoolExhausted);
386 }
387 let mut entry = None;
388 for candidate in &available {
389 let issues = worktree_issues(candidate);
390 if issues.is_empty() {
391 entry = Some(*candidate);
392 break;
393 }
394 eprintln!(
396 "\x1b[0;33m\u{26a0}\x1b[0m Skipping unclean worktree {}: {}",
397 candidate.name,
398 issues.join("; ")
399 );
400 }
401 let entry = entry.ok_or(GwError::PoolNoCleanWorktree)?;
402
403 let owner = leader_name()?;
405 std::fs::write(acquired_dir.join(&entry.name), &owner)?;
406
407 let wt_path = entry.path.to_string_lossy().to_string();
409 git::git_run_in_dir(&wt_path, &["fetch", "--prune"], verbose)?;
410 let default_remote = git::get_default_remote_branch()?;
411 let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
412 git::git_run_in_dir(&wt_path, &["pull", "origin", default_branch], verbose)?;
413
414 let path = entry.path.to_string_lossy().to_string();
415 let name = entry.name.clone();
416
417 let remaining = state.count_by_status(&WorktreeStatus::Available) - 1;
418 eprintln!(
419 "\x1b[0;32m\u{2713}\x1b[0m Acquired {} ({} remaining)",
420 name, remaining,
421 );
422
423 println!("{path}");
425
426 Ok(())
427}
428
429pub fn release(name: Option<String>, _verbose: bool) -> Result<()> {
434 if !git::is_git_repo() {
435 return Err(GwError::NotAGitRepository);
436 }
437
438 let pool_dir = pool_dir()?;
439 let wt_dir = worktrees_dir()?;
440 let acquired_dir = acquired_dir()?;
441 let prefix = pool_prefix()?;
442
443 if !wt_dir.exists() {
444 return Err(GwError::PoolNotInitialized);
445 }
446
447 let _lock = PoolLock::acquire(&pool_dir)?;
448 let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
449
450 if state.entries.is_empty() {
451 return Err(GwError::PoolNotInitialized);
452 }
453
454 match name {
455 Some(ref n) => {
456 let entry = state
457 .find_by_name_or_path(n)
458 .ok_or_else(|| GwError::PoolWorktreeNotFound(n.clone()))?;
459
460 if entry.status != WorktreeStatus::Acquired {
461 return Err(GwError::PoolWorktreeNotAcquired(entry.name.clone()));
462 }
463
464 let issues = worktree_issues(entry);
467 if !issues.is_empty() {
468 return Err(GwError::PoolWorktreeDirty {
469 name: entry.name.clone(),
470 reason: issues.join("; "),
471 });
472 }
473
474 release_one(entry, &acquired_dir)?;
475 }
476 None => {
477 let acquired: Vec<_> = state
478 .entries
479 .iter()
480 .filter(|e| e.status == WorktreeStatus::Acquired)
481 .collect();
482
483 if acquired.is_empty() {
484 return Err(GwError::PoolNoneAcquired);
485 }
486
487 let mut skipped = Vec::new();
490 for entry in &acquired {
491 let issues = worktree_issues(entry);
492 if issues.is_empty() {
493 release_one(entry, &acquired_dir)?;
494 } else {
495 output::warn(&format!("Kept {}: {}", entry.name, issues.join("; ")));
496 skipped.push(entry.name.clone());
497 }
498 }
499
500 if !skipped.is_empty() {
501 return Err(GwError::Other(format!(
502 "Kept {} unclean worktree(s) acquired: {}. Run `gw cleanup` inside each \
503 (or fix it), then `gw worktree pool release <name>`.",
504 skipped.len(),
505 skipped.join(", ")
506 )));
507 }
508 }
509 }
510
511 let final_state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
513 let available = final_state.count_by_status(&WorktreeStatus::Available);
514 let acquired_count = final_state.count_by_status(&WorktreeStatus::Acquired);
515 let total = final_state.entries.len();
516
517 println!();
518 output::success(&format!(
519 "Pool: {} available, {} acquired, {} total",
520 available, acquired_count, total
521 ));
522
523 Ok(())
524}
525
526pub fn status(verbose: bool) -> Result<()> {
528 if !git::is_git_repo() {
529 return Err(GwError::NotAGitRepository);
530 }
531
532 let wt_dir = worktrees_dir()?;
533 let acquired_dir = acquired_dir()?;
534 let prefix = pool_prefix()?;
535
536 if !wt_dir.exists() {
537 return Err(GwError::PoolNotInitialized);
538 }
539
540 let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
542
543 if state.entries.is_empty() {
544 return Err(GwError::PoolNotInitialized);
545 }
546
547 let available = state.count_by_status(&WorktreeStatus::Available);
548 let acquired = state.count_by_status(&WorktreeStatus::Acquired);
549 let total = state.entries.len();
550
551 println!();
552 output::info(&format!(
553 "Pool: {} available, {} acquired, {} total",
554 output::bold(&available.to_string()),
555 output::bold(&acquired.to_string()),
556 output::bold(&total.to_string()),
557 ));
558
559 if acquired > 0 {
560 println!();
561 let header = format!("{:<24} {}", "NAME", "BRANCH");
562 println!("{header}");
563 println!("{}", "-".repeat(48));
564
565 for entry in &state.entries {
566 if entry.status != WorktreeStatus::Acquired {
567 continue;
568 }
569 let branch = worktree_current_branch(&entry.path);
570 let branch_display = if branch == entry.name {
571 "(idle)".to_string()
572 } else {
573 branch
574 };
575 println!("{:<24} {}", entry.name, branch_display);
576 println!(" {}", entry.path.display());
577 }
578 }
579
580 if verbose {
581 println!();
583 output::info("All entries:");
584 println!();
585 let header = format!("{:<24} {:<12} {:<24}", "NAME", "STATUS", "BRANCH");
586 println!("{header}");
587 println!("{}", "-".repeat(60));
588
589 for entry in &state.entries {
590 let branch = if entry.status == WorktreeStatus::Acquired {
591 worktree_current_branch(&entry.path)
592 } else {
593 entry.branch.clone()
594 };
595 println!("{:<24} {:<12} {}", entry.name, entry.status, branch);
596 }
597 }
598
599 let next = state.next_action();
601 println!();
602 display_pool_next_action(&next);
603
604 println!();
605 Ok(())
606}
607
608fn display_pool_next_action(action: &PoolNextAction) {
609 match action {
610 PoolNextAction::WarmPool => {
611 output::action("Next: warm the pool");
612 println!(" gw worktree pool warm <count>");
613 }
614 PoolNextAction::Ready { available } => {
615 output::action(&format!("Ready: {} worktree(s) available", available));
616 println!(" gw worktree pool acquire");
617 println!(" gw worktree pool release [name]");
618 }
619 PoolNextAction::Exhausted { acquired } => {
620 output::action(&format!(
621 "All {} worktree(s) acquired. Release or warm more.",
622 acquired
623 ));
624 println!(" gw worktree pool release [name]");
625 println!(" gw worktree pool warm <count>");
626 }
627 PoolNextAction::AllIdle { available } => {
628 output::action(&format!(
629 "All {} worktree(s) idle. Acquire or drain.",
630 available
631 ));
632 println!(" gw worktree pool acquire");
633 println!(" gw worktree pool drain");
634 }
635 }
636}
637
638pub fn drain(force: bool, verbose: bool) -> Result<()> {
640 if !git::is_git_repo() {
641 return Err(GwError::NotAGitRepository);
642 }
643
644 let pool_dir = pool_dir()?;
645 let wt_dir = worktrees_dir()?;
646 let acquired_dir = acquired_dir()?;
647 let prefix = pool_prefix()?;
648 let leader = leader_root()?;
651 let leader_str = leader.to_string_lossy().to_string();
652
653 if !wt_dir.exists() {
654 return Err(GwError::PoolNotInitialized);
655 }
656
657 println!();
658 output::info("Draining worktree pool...");
659
660 let _lock = PoolLock::acquire(&pool_dir)?;
661 let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
662
663 if state.entries.is_empty() {
664 return Err(GwError::PoolNotInitialized);
665 }
666
667 let acquired = state.count_by_status(&WorktreeStatus::Acquired);
669 if acquired > 0 && !force {
670 return Err(GwError::PoolHasAcquiredWorktrees(acquired));
671 }
672
673 let total = state.entries.len();
674
675 for (i, entry) in state.entries.iter().enumerate() {
676 output::info(&format!(
677 "[{}/{}] Removing {}...",
678 i + 1,
679 total,
680 output::bold(&entry.name)
681 ));
682
683 let path_str = entry.path.to_string_lossy().to_string();
684
685 if let Err(e) = git::git_run_in_dir(
687 &leader_str,
688 &["worktree", "remove", "--force", &path_str],
689 verbose,
690 ) {
691 output::warn(&format!("Failed to remove worktree {}: {e}", entry.name));
692 let _ = std::fs::remove_dir_all(&entry.path);
693 }
694
695 if let Err(e) = git::git_run_in_dir(&leader_str, &["branch", "-D", &entry.branch], verbose)
697 {
698 output::warn(&format!("Failed to delete branch {}: {e}", entry.branch));
699 }
700
701 let marker = acquired_dir.join(&entry.name);
703 let _ = std::fs::remove_file(&marker);
704
705 output::success(&format!("[{}/{}] Removed {}", i + 1, total, entry.name));
706 }
707
708 if acquired_dir.exists() {
710 let _ = std::fs::remove_dir_all(&acquired_dir);
711 }
712 let _ = std::fs::remove_file(pool_dir.join("pool.lock"));
713
714 git::git_run_in_dir(&leader_str, &["worktree", "prune"], verbose)?;
716
717 if wt_dir.exists() {
719 let _ = std::fs::remove_dir(&wt_dir);
720 }
721 drop(_lock);
722 let _ = std::fs::remove_dir(&pool_dir);
723
724 println!();
725 output::success(&format!("Drained {total} worktree(s) from pool"));
726
727 Ok(())
728}