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();
410 git::git_run_in_dir(&wt_path, &["fetch", "--prune"], verbose)?;
411 let default_remote = git::get_default_remote_branch()?;
412 let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
413 git::git_run_in_dir(
414 &wt_path,
415 &["pull", "--ff-only", "origin", default_branch],
416 verbose,
417 )?;
418
419 let path = entry.path.to_string_lossy().to_string();
420 let name = entry.name.clone();
421
422 let remaining = state.count_by_status(&WorktreeStatus::Available) - 1;
423 eprintln!(
424 "\x1b[0;32m\u{2713}\x1b[0m Acquired {} ({} remaining)",
425 name, remaining,
426 );
427
428 println!("{path}");
430
431 Ok(())
432}
433
434pub fn release(name: Option<String>, _verbose: bool) -> Result<()> {
439 if !git::is_git_repo() {
440 return Err(GwError::NotAGitRepository);
441 }
442
443 let pool_dir = pool_dir()?;
444 let wt_dir = worktrees_dir()?;
445 let acquired_dir = acquired_dir()?;
446 let prefix = pool_prefix()?;
447
448 if !wt_dir.exists() {
449 return Err(GwError::PoolNotInitialized);
450 }
451
452 let _lock = PoolLock::acquire(&pool_dir)?;
453 let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
454
455 if state.entries.is_empty() {
456 return Err(GwError::PoolNotInitialized);
457 }
458
459 match name {
460 Some(ref n) => {
461 let entry = state
462 .find_by_name_or_path(n)
463 .ok_or_else(|| GwError::PoolWorktreeNotFound(n.clone()))?;
464
465 if entry.status != WorktreeStatus::Acquired {
466 return Err(GwError::PoolWorktreeNotAcquired(entry.name.clone()));
467 }
468
469 let issues = worktree_issues(entry);
472 if !issues.is_empty() {
473 return Err(GwError::PoolWorktreeDirty {
474 name: entry.name.clone(),
475 reason: issues.join("; "),
476 });
477 }
478
479 release_one(entry, &acquired_dir)?;
480 }
481 None => {
482 let acquired: Vec<_> = state
483 .entries
484 .iter()
485 .filter(|e| e.status == WorktreeStatus::Acquired)
486 .collect();
487
488 if acquired.is_empty() {
489 return Err(GwError::PoolNoneAcquired);
490 }
491
492 let mut skipped = Vec::new();
495 for entry in &acquired {
496 let issues = worktree_issues(entry);
497 if issues.is_empty() {
498 release_one(entry, &acquired_dir)?;
499 } else {
500 output::warn(&format!("Kept {}: {}", entry.name, issues.join("; ")));
501 skipped.push(entry.name.clone());
502 }
503 }
504
505 if !skipped.is_empty() {
506 return Err(GwError::Other(format!(
507 "Kept {} unclean worktree(s) acquired: {}. Run `gw cleanup` inside each \
508 (or fix it), then `gw worktree pool release <name>`.",
509 skipped.len(),
510 skipped.join(", ")
511 )));
512 }
513 }
514 }
515
516 let final_state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
518 let available = final_state.count_by_status(&WorktreeStatus::Available);
519 let acquired_count = final_state.count_by_status(&WorktreeStatus::Acquired);
520 let total = final_state.entries.len();
521
522 println!();
523 output::success(&format!(
524 "Pool: {} available, {} acquired, {} total",
525 available, acquired_count, total
526 ));
527
528 Ok(())
529}
530
531pub fn status(verbose: bool) -> Result<()> {
533 if !git::is_git_repo() {
534 return Err(GwError::NotAGitRepository);
535 }
536
537 let wt_dir = worktrees_dir()?;
538 let acquired_dir = acquired_dir()?;
539 let prefix = pool_prefix()?;
540
541 if !wt_dir.exists() {
542 return Err(GwError::PoolNotInitialized);
543 }
544
545 let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
547
548 if state.entries.is_empty() {
549 return Err(GwError::PoolNotInitialized);
550 }
551
552 let available = state.count_by_status(&WorktreeStatus::Available);
553 let acquired = state.count_by_status(&WorktreeStatus::Acquired);
554 let total = state.entries.len();
555
556 println!();
557 output::info(&format!(
558 "Pool: {} available, {} acquired, {} total",
559 output::bold(&available.to_string()),
560 output::bold(&acquired.to_string()),
561 output::bold(&total.to_string()),
562 ));
563
564 if acquired > 0 {
565 println!();
566 let header = format!("{:<24} {}", "NAME", "BRANCH");
567 println!("{header}");
568 println!("{}", "-".repeat(48));
569
570 for entry in &state.entries {
571 if entry.status != WorktreeStatus::Acquired {
572 continue;
573 }
574 let branch = worktree_current_branch(&entry.path);
575 let branch_display = if branch == entry.name {
576 "(idle)".to_string()
577 } else {
578 branch
579 };
580 println!("{:<24} {}", entry.name, branch_display);
581 println!(" {}", entry.path.display());
582 }
583 }
584
585 if verbose {
586 println!();
588 output::info("All entries:");
589 println!();
590 let header = format!("{:<24} {:<12} {:<24}", "NAME", "STATUS", "BRANCH");
591 println!("{header}");
592 println!("{}", "-".repeat(60));
593
594 for entry in &state.entries {
595 let branch = if entry.status == WorktreeStatus::Acquired {
596 worktree_current_branch(&entry.path)
597 } else {
598 entry.branch.clone()
599 };
600 println!("{:<24} {:<12} {}", entry.name, entry.status, branch);
601 }
602 }
603
604 let next = state.next_action();
606 println!();
607 display_pool_next_action(&next);
608
609 println!();
610 Ok(())
611}
612
613fn display_pool_next_action(action: &PoolNextAction) {
614 match action {
615 PoolNextAction::WarmPool => {
616 output::action("Next: warm the pool");
617 println!(" gw worktree pool warm <count>");
618 }
619 PoolNextAction::Ready { available } => {
620 output::action(&format!("Ready: {} worktree(s) available", available));
621 println!(" gw worktree pool acquire");
622 println!(" gw worktree pool release [name]");
623 }
624 PoolNextAction::Exhausted { acquired } => {
625 output::action(&format!(
626 "All {} worktree(s) acquired. Release or warm more.",
627 acquired
628 ));
629 println!(" gw worktree pool release [name]");
630 println!(" gw worktree pool warm <count>");
631 }
632 PoolNextAction::AllIdle { available } => {
633 output::action(&format!(
634 "All {} worktree(s) idle. Acquire or drain.",
635 available
636 ));
637 println!(" gw worktree pool acquire");
638 println!(" gw worktree pool drain");
639 }
640 }
641}
642
643pub fn drain(force: bool, verbose: bool) -> Result<()> {
645 if !git::is_git_repo() {
646 return Err(GwError::NotAGitRepository);
647 }
648
649 let pool_dir = pool_dir()?;
650 let wt_dir = worktrees_dir()?;
651 let acquired_dir = acquired_dir()?;
652 let prefix = pool_prefix()?;
653 let leader = leader_root()?;
656 let leader_str = leader.to_string_lossy().to_string();
657
658 if !wt_dir.exists() {
659 return Err(GwError::PoolNotInitialized);
660 }
661
662 println!();
663 output::info("Draining worktree pool...");
664
665 let _lock = PoolLock::acquire(&pool_dir)?;
666 let state = PoolState::scan(&wt_dir, &acquired_dir, &prefix)?;
667
668 if state.entries.is_empty() {
669 return Err(GwError::PoolNotInitialized);
670 }
671
672 let acquired = state.count_by_status(&WorktreeStatus::Acquired);
674 if acquired > 0 && !force {
675 return Err(GwError::PoolHasAcquiredWorktrees(acquired));
676 }
677
678 let total = state.entries.len();
679
680 for (i, entry) in state.entries.iter().enumerate() {
681 output::info(&format!(
682 "[{}/{}] Removing {}...",
683 i + 1,
684 total,
685 output::bold(&entry.name)
686 ));
687
688 let path_str = entry.path.to_string_lossy().to_string();
689
690 if let Err(e) = git::git_run_in_dir(
692 &leader_str,
693 &["worktree", "remove", "--force", &path_str],
694 verbose,
695 ) {
696 output::warn(&format!("Failed to remove worktree {}: {e}", entry.name));
697 let _ = std::fs::remove_dir_all(&entry.path);
698 }
699
700 if let Err(e) = git::git_run_in_dir(&leader_str, &["branch", "-D", &entry.branch], verbose)
702 {
703 output::warn(&format!("Failed to delete branch {}: {e}", entry.branch));
704 }
705
706 let marker = acquired_dir.join(&entry.name);
708 let _ = std::fs::remove_file(&marker);
709
710 output::success(&format!("[{}/{}] Removed {}", i + 1, total, entry.name));
711 }
712
713 if acquired_dir.exists() {
715 let _ = std::fs::remove_dir_all(&acquired_dir);
716 }
717 let _ = std::fs::remove_file(pool_dir.join("pool.lock"));
718
719 git::git_run_in_dir(&leader_str, &["worktree", "prune"], verbose)?;
721
722 if wt_dir.exists() {
724 let _ = std::fs::remove_dir(&wt_dir);
725 }
726 drop(_lock);
727 let _ = std::fs::remove_dir(&pool_dir);
728
729 println!();
730 output::success(&format!("Drained {total} worktree(s) from pool"));
731
732 Ok(())
733}