1#![allow(dead_code)]
9
10use std::collections::HashSet;
11use std::ffi::OsStr;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Output};
14
15use anyhow::{Context, Result, bail};
16use tracing::{info, warn};
17
18#[derive(Debug, Clone)]
19pub struct PhaseWorktree {
20 pub repo_root: PathBuf,
21 pub base_branch: String,
22 pub start_commit: String,
23 pub branch: String,
24 pub path: PathBuf,
25}
26
27#[derive(Debug, Clone)]
28pub struct AgentWorktree {
29 pub branch: String,
30 pub path: PathBuf,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum RunOutcome {
35 Completed,
36 Failed,
37 DryRun,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CleanupDecision {
42 Cleaned,
43 KeptForReview,
44 KeptForFailure,
45}
46
47impl PhaseWorktree {
48 pub fn finalize(&self, outcome: RunOutcome) -> Result<CleanupDecision> {
49 match outcome {
50 RunOutcome::Failed => Ok(CleanupDecision::KeptForFailure),
51 RunOutcome::DryRun => {
52 remove_worktree(&self.repo_root, &self.path)?;
53 delete_branch(&self.repo_root, &self.branch)?;
54 Ok(CleanupDecision::Cleaned)
55 }
56 RunOutcome::Completed => {
57 let branch_tip = current_commit(&self.repo_root, &self.branch)?;
58 if branch_tip == self.start_commit {
59 return Ok(CleanupDecision::KeptForReview);
60 }
61
62 if is_merged_into_base(&self.repo_root, &self.branch, &self.base_branch)? {
63 remove_worktree(&self.repo_root, &self.path)?;
64 delete_branch(&self.repo_root, &self.branch)?;
65 Ok(CleanupDecision::Cleaned)
66 } else {
67 Ok(CleanupDecision::KeptForReview)
68 }
69 }
70 }
71 }
72}
73
74pub fn prepare_phase_worktree(project_root: &Path, phase: &str) -> Result<PhaseWorktree> {
76 let repo_root = resolve_repo_root(project_root)?;
77 let base_branch = current_branch(&repo_root)?;
78 let start_commit = current_commit(&repo_root, "HEAD")?;
79 let worktrees_root = repo_root.join(".batty").join("worktrees");
80
81 std::fs::create_dir_all(&worktrees_root).with_context(|| {
82 format!(
83 "failed to create worktrees directory {}",
84 worktrees_root.display()
85 )
86 })?;
87
88 let phase_slug = sanitize_phase_for_branch(phase);
89 let prefix = format!("{phase_slug}-run-");
90 let mut run_number = next_run_number(&repo_root, &worktrees_root, &prefix)?;
91
92 loop {
93 let branch = format!("{prefix}{run_number:03}");
94 let path = worktrees_root.join(&branch);
95
96 if path.exists() || branch_exists(&repo_root, &branch)? {
97 run_number += 1;
98 continue;
99 }
100
101 let path_s = path.to_string_lossy().to_string();
102 let add_output = run_git(
103 &repo_root,
104 [
105 "worktree",
106 "add",
107 "-b",
108 branch.as_str(),
109 path_s.as_str(),
110 base_branch.as_str(),
111 ],
112 )?;
113 if !add_output.status.success() {
114 bail!(
115 "git worktree add failed: {}",
116 String::from_utf8_lossy(&add_output.stderr).trim()
117 );
118 }
119
120 return Ok(PhaseWorktree {
121 repo_root,
122 base_branch,
123 start_commit,
124 branch,
125 path,
126 });
127 }
128}
129
130pub fn resolve_phase_worktree(
138 project_root: &Path,
139 phase: &str,
140 force_new: bool,
141) -> Result<(PhaseWorktree, bool)> {
142 if !force_new && let Some(existing) = latest_phase_worktree(project_root, phase)? {
143 return Ok((existing, true));
144 }
145
146 Ok((prepare_phase_worktree(project_root, phase)?, false))
147}
148
149pub fn prepare_agent_worktrees(
155 project_root: &Path,
156 phase: &str,
157 agent_names: &[String],
158 force_new: bool,
159) -> Result<Vec<AgentWorktree>> {
160 if agent_names.is_empty() {
161 bail!("parallel agent worktree preparation requires at least one agent");
162 }
163
164 let repo_root = resolve_repo_root(project_root)?;
165 let base_branch = current_branch(&repo_root)?;
166 let phase_slug = sanitize_phase_for_branch(phase);
167 let phase_dir = repo_root.join(".batty").join("worktrees").join(phase);
168 std::fs::create_dir_all(&phase_dir).with_context(|| {
169 format!(
170 "failed to create agent worktree phase directory {}",
171 phase_dir.display()
172 )
173 })?;
174
175 let mut seen_agent_slugs = HashSet::new();
176 for agent in agent_names {
177 let slug = sanitize_phase_for_branch(agent);
178 if !seen_agent_slugs.insert(slug.clone()) {
179 bail!(
180 "agent names contain duplicate sanitized slug '{}'; use unique agent names",
181 slug
182 );
183 }
184 }
185
186 let mut worktrees = Vec::with_capacity(agent_names.len());
187 for agent in agent_names {
188 let agent_slug = sanitize_phase_for_branch(agent);
189 let branch = format!("batty/{phase_slug}/{agent_slug}");
190 let path = phase_dir.join(&agent_slug);
191
192 if force_new {
193 let _ = remove_worktree(&repo_root, &path);
194 let _ = delete_branch(&repo_root, &branch);
195 }
196
197 if path.exists() {
198 if !branch_exists(&repo_root, &branch)? {
199 bail!(
200 "agent worktree path exists but branch is missing: {} ({})",
201 path.display(),
202 branch
203 );
204 }
205 if !worktree_registered(&repo_root, &path)? {
206 bail!(
207 "agent worktree path exists but is not registered in git worktree list: {}",
208 path.display()
209 );
210 }
211 } else {
212 let path_s = path.to_string_lossy().to_string();
213 let add_output = if branch_exists(&repo_root, &branch)? {
214 run_git(
215 &repo_root,
216 ["worktree", "add", path_s.as_str(), branch.as_str()],
217 )?
218 } else {
219 run_git(
220 &repo_root,
221 [
222 "worktree",
223 "add",
224 "-b",
225 branch.as_str(),
226 path_s.as_str(),
227 base_branch.as_str(),
228 ],
229 )?
230 };
231 if !add_output.status.success() {
232 bail!(
233 "git worktree add failed for agent '{}': {}",
234 agent,
235 String::from_utf8_lossy(&add_output.stderr).trim()
236 );
237 }
238 }
239
240 worktrees.push(AgentWorktree { branch, path });
241 }
242
243 Ok(worktrees)
244}
245
246fn latest_phase_worktree(project_root: &Path, phase: &str) -> Result<Option<PhaseWorktree>> {
247 let repo_root = resolve_repo_root(project_root)?;
248 let base_branch = current_branch(&repo_root)?;
249 let worktrees_root = repo_root.join(".batty").join("worktrees");
250 if !worktrees_root.is_dir() {
251 return Ok(None);
252 }
253
254 let phase_slug = sanitize_phase_for_branch(phase);
255 let prefix = format!("{phase_slug}-run-");
256 let mut best: Option<(u32, String, PathBuf)> = None;
257
258 for entry in std::fs::read_dir(&worktrees_root)
259 .with_context(|| format!("failed to read {}", worktrees_root.display()))?
260 {
261 let entry = entry?;
262 let path = entry.path();
263 if !path.is_dir() {
264 continue;
265 }
266
267 let branch = entry.file_name().to_string_lossy().to_string();
268 let Some(run) = parse_run_number(&branch, &prefix) else {
269 continue;
270 };
271
272 if !branch_exists(&repo_root, &branch)? {
273 warn!(
274 branch = %branch,
275 path = %path.display(),
276 "skipping stale phase worktree directory without branch"
277 );
278 continue;
279 }
280
281 match &best {
282 Some((best_run, _, _)) if run <= *best_run => {}
283 _ => best = Some((run, branch, path)),
284 }
285 }
286
287 let Some((_, branch, path)) = best else {
288 return Ok(None);
289 };
290
291 let start_commit = current_commit(&repo_root, &branch)?;
292 Ok(Some(PhaseWorktree {
293 repo_root,
294 base_branch,
295 start_commit,
296 branch,
297 path,
298 }))
299}
300
301fn resolve_repo_root(project_root: &Path) -> Result<PathBuf> {
302 let output = Command::new("git")
303 .current_dir(project_root)
304 .args(["rev-parse", "--show-toplevel"])
305 .output()
306 .with_context(|| {
307 format!(
308 "failed while trying to resolve the repository root: could not execute `git rev-parse --show-toplevel` in {}",
309 project_root.display()
310 )
311 })?;
312 if !output.status.success() {
313 bail!(
314 "not a git repository: {}",
315 String::from_utf8_lossy(&output.stderr).trim()
316 );
317 }
318
319 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
320 if root.is_empty() {
321 bail!("git rev-parse returned empty repository root");
322 }
323 Ok(PathBuf::from(root))
324}
325
326fn current_branch(repo_root: &Path) -> Result<String> {
327 let output = run_git(repo_root, ["branch", "--show-current"])?;
328 if !output.status.success() {
329 bail!(
330 "failed to determine current branch: {}",
331 String::from_utf8_lossy(&output.stderr).trim()
332 );
333 }
334
335 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
336 if branch.is_empty() {
337 bail!("detached HEAD is not supported for phase worktree runs; checkout a branch first");
338 }
339 Ok(branch)
340}
341
342fn next_run_number(repo_root: &Path, worktrees_root: &Path, prefix: &str) -> Result<u32> {
343 let mut max_run = 0;
344
345 let refs = run_git(
346 repo_root,
347 ["for-each-ref", "--format=%(refname:short)", "refs/heads"],
348 )?;
349 if !refs.status.success() {
350 bail!(
351 "failed to list branches: {}",
352 String::from_utf8_lossy(&refs.stderr).trim()
353 );
354 }
355
356 for branch in String::from_utf8_lossy(&refs.stdout).lines() {
357 if let Some(run) = parse_run_number(branch, prefix) {
358 max_run = max_run.max(run);
359 }
360 }
361
362 if worktrees_root.is_dir() {
363 for entry in std::fs::read_dir(worktrees_root)
364 .with_context(|| format!("failed to read {}", worktrees_root.display()))?
365 {
366 let entry = entry?;
367 let name = entry.file_name();
368 let name = name.to_string_lossy();
369 if let Some(run) = parse_run_number(name.as_ref(), prefix) {
370 max_run = max_run.max(run);
371 }
372 }
373 }
374
375 Ok(max_run + 1)
376}
377
378fn parse_run_number(name: &str, prefix: &str) -> Option<u32> {
379 let suffix = name.strip_prefix(prefix)?;
380 if suffix.len() < 3 || !suffix.chars().all(|c| c.is_ascii_digit()) {
381 return None;
382 }
383 suffix.parse().ok()
384}
385
386fn sanitize_phase_for_branch(phase: &str) -> String {
387 let mut out = String::new();
388 let mut last_dash = false;
389
390 for c in phase.chars() {
391 if c.is_ascii_alphanumeric() {
392 out.push(c.to_ascii_lowercase());
393 last_dash = false;
394 } else if !last_dash {
395 out.push('-');
396 last_dash = true;
397 }
398 }
399
400 let slug = out.trim_matches('-').to_string();
401 if slug.is_empty() {
402 "phase".to_string()
403 } else {
404 slug
405 }
406}
407
408fn run_git<I, S>(repo_root: &Path, args: I) -> Result<Output>
409where
410 I: IntoIterator<Item = S>,
411 S: AsRef<OsStr>,
412{
413 let args = args
414 .into_iter()
415 .map(|arg| arg.as_ref().to_os_string())
416 .collect::<Vec<_>>();
417 let command = {
418 let rendered = args
419 .iter()
420 .map(|arg| arg.to_string_lossy().into_owned())
421 .collect::<Vec<_>>()
422 .join(" ");
423 format!("git {rendered}")
424 };
425 Command::new("git")
426 .current_dir(repo_root)
427 .args(&args)
428 .output()
429 .with_context(|| format!("failed to execute `{command}` in {}", repo_root.display()))
430}
431
432fn branch_exists(repo_root: &Path, branch: &str) -> Result<bool> {
433 let ref_name = format!("refs/heads/{branch}");
434 let output = run_git(
435 repo_root,
436 ["show-ref", "--verify", "--quiet", ref_name.as_str()],
437 )?;
438 match output.status.code() {
439 Some(0) => Ok(true),
440 Some(1) => Ok(false),
441 _ => bail!(
442 "failed to check branch '{}': {}",
443 branch,
444 String::from_utf8_lossy(&output.stderr).trim()
445 ),
446 }
447}
448
449fn worktree_registered(repo_root: &Path, path: &Path) -> Result<bool> {
450 let output = run_git(repo_root, ["worktree", "list", "--porcelain"])?;
451 if !output.status.success() {
452 bail!(
453 "failed to list worktrees: {}",
454 String::from_utf8_lossy(&output.stderr).trim()
455 );
456 }
457
458 let target = path.to_string_lossy().to_string();
459 let listed = String::from_utf8_lossy(&output.stdout);
460 for line in listed.lines() {
461 if let Some(candidate) = line.strip_prefix("worktree ")
462 && candidate.trim() == target
463 {
464 return Ok(true);
465 }
466 }
467 Ok(false)
468}
469
470fn is_merged_into_base(repo_root: &Path, branch: &str, base_branch: &str) -> Result<bool> {
471 let output = run_git(
472 repo_root,
473 ["merge-base", "--is-ancestor", branch, base_branch],
474 )?;
475 match output.status.code() {
476 Some(0) => Ok(true),
477 Some(1) => Ok(false),
478 _ => bail!(
479 "failed to check merge status for '{}' into '{}': {}",
480 branch,
481 base_branch,
482 String::from_utf8_lossy(&output.stderr).trim()
483 ),
484 }
485}
486
487fn current_commit(repo_root: &Path, rev: &str) -> Result<String> {
488 let output = run_git(repo_root, ["rev-parse", rev])?;
489 if !output.status.success() {
490 bail!(
491 "failed to resolve revision '{}': {}",
492 rev,
493 String::from_utf8_lossy(&output.stderr).trim()
494 );
495 }
496
497 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
498 if commit.is_empty() {
499 bail!("git rev-parse returned empty commit for '{rev}'");
500 }
501 Ok(commit)
502}
503
504fn remove_worktree(repo_root: &Path, path: &Path) -> Result<()> {
505 if !path.exists() {
506 return Ok(());
507 }
508
509 let path_s = path.to_string_lossy().to_string();
510 let output = run_git(
511 repo_root,
512 ["worktree", "remove", "--force", path_s.as_str()],
513 )?;
514 if !output.status.success() {
515 bail!(
516 "failed to remove worktree '{}': {}",
517 path.display(),
518 String::from_utf8_lossy(&output.stderr).trim()
519 );
520 }
521 Ok(())
522}
523
524fn delete_branch(repo_root: &Path, branch: &str) -> Result<()> {
525 if !branch_exists(repo_root, branch)? {
526 return Ok(());
527 }
528
529 let output = run_git(repo_root, ["branch", "-D", branch])?;
530 if !output.status.success() {
531 bail!(
532 "failed to delete branch '{}': {}",
533 branch,
534 String::from_utf8_lossy(&output.stderr).trim()
535 );
536 }
537 Ok(())
538}
539
540pub fn sync_phase_board_to_worktree(
550 project_root: &Path,
551 worktree_root: &Path,
552 phase: &str,
553) -> Result<()> {
554 let source_phase_dir = crate::paths::resolve_kanban_root(project_root).join(phase);
555 if !source_phase_dir.is_dir() {
556 return Ok(());
557 }
558
559 let dest_kanban_root = crate::paths::resolve_kanban_root(worktree_root);
560 let dest_phase_dir = dest_kanban_root.join(phase);
561
562 if dest_phase_dir.exists() {
564 std::fs::remove_dir_all(&dest_phase_dir).with_context(|| {
565 format!(
566 "failed to remove stale phase board at {}",
567 dest_phase_dir.display()
568 )
569 })?;
570 }
571
572 copy_dir_recursive(&source_phase_dir, &dest_phase_dir).with_context(|| {
573 format!(
574 "failed to sync phase board from {} to {}",
575 source_phase_dir.display(),
576 dest_phase_dir.display()
577 )
578 })?;
579
580 info!(
581 phase = phase,
582 source = %source_phase_dir.display(),
583 dest = %dest_phase_dir.display(),
584 "synced phase board into worktree"
585 );
586 Ok(())
587}
588
589pub fn branch_fully_merged(repo_root: &Path, branch: &str, base: &str) -> Result<bool> {
596 let output = run_git(repo_root, ["cherry", base, branch])?;
597 if !output.status.success() {
598 bail!(
599 "git cherry failed for '{}' against '{}': {}",
600 branch,
601 base,
602 String::from_utf8_lossy(&output.stderr).trim()
603 );
604 }
605
606 let stdout = String::from_utf8_lossy(&output.stdout);
607 for line in stdout.lines() {
608 let trimmed = line.trim();
609 if trimmed.is_empty() {
610 continue;
611 }
612 if trimmed.starts_with('+') {
614 return Ok(false);
615 }
616 }
617 Ok(true)
618}
619
620pub fn git_current_branch(path: &Path) -> Result<String> {
622 let output = run_git(path, ["branch", "--show-current"])?;
623 if !output.status.success() {
624 bail!(
625 "failed to determine current branch in {}: {}",
626 path.display(),
627 String::from_utf8_lossy(&output.stderr).trim()
628 );
629 }
630 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
631 if branch.is_empty() {
632 bail!(
633 "detached HEAD in {}; cannot determine branch",
634 path.display()
635 );
636 }
637 Ok(branch)
638}
639
640pub fn reset_worktree_to_base(worktree_path: &Path, base_branch: &str) -> Result<()> {
643 let checkout = run_git(worktree_path, ["checkout", base_branch])?;
644 if !checkout.status.success() {
645 bail!(
646 "failed to checkout '{}' in {}: {}",
647 base_branch,
648 worktree_path.display(),
649 String::from_utf8_lossy(&checkout.stderr).trim()
650 );
651 }
652 let reset = run_git(worktree_path, ["reset", "--hard", "main"])?;
653 if !reset.status.success() {
654 bail!(
655 "failed to reset to main in {}: {}",
656 worktree_path.display(),
657 String::from_utf8_lossy(&reset.stderr).trim()
658 );
659 }
660 Ok(())
661}
662
663fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
664 std::fs::create_dir_all(dst)?;
665 for entry in std::fs::read_dir(src)? {
666 let entry = entry?;
667 let src_path = entry.path();
668 let dst_path = dst.join(entry.file_name());
669 if src_path.is_dir() {
670 copy_dir_recursive(&src_path, &dst_path)?;
671 } else {
672 std::fs::copy(&src_path, &dst_path)?;
673 }
674 }
675 Ok(())
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use std::fs;
682
683 fn git_available() -> bool {
684 Command::new("git")
685 .arg("--version")
686 .output()
687 .map(|o| o.status.success())
688 .unwrap_or(false)
689 }
690
691 fn git(repo: &Path, args: &[&str]) {
692 let output = Command::new("git")
693 .current_dir(repo)
694 .args(args)
695 .output()
696 .unwrap();
697 assert!(
698 output.status.success(),
699 "git {:?} failed: {}",
700 args,
701 String::from_utf8_lossy(&output.stderr)
702 );
703 }
704
705 fn init_repo() -> Option<tempfile::TempDir> {
706 if !git_available() {
707 return None;
708 }
709
710 let tmp = tempfile::tempdir().unwrap();
711 git(tmp.path(), &["init", "-q", "-b", "main"]);
712 git(
713 tmp.path(),
714 &["config", "user.email", "batty-test@example.com"],
715 );
716 git(tmp.path(), &["config", "user.name", "Batty Test"]);
717
718 fs::write(tmp.path().join("README.md"), "init\n").unwrap();
719 git(tmp.path(), &["add", "README.md"]);
720 git(tmp.path(), &["commit", "-q", "-m", "init"]);
721
722 Some(tmp)
723 }
724
725 fn cleanup_worktree(repo_root: &Path, worktree: &PhaseWorktree) {
726 let _ = remove_worktree(repo_root, &worktree.path);
727 let _ = delete_branch(repo_root, &worktree.branch);
728 }
729
730 fn cleanup_agent_worktrees(repo_root: &Path, worktrees: &[AgentWorktree]) {
731 for wt in worktrees {
732 let _ = remove_worktree(repo_root, &wt.path);
733 let _ = delete_branch(repo_root, &wt.branch);
734 }
735 }
736
737 #[test]
738 fn sanitize_phase_for_branch_normalizes_phase() {
739 assert_eq!(sanitize_phase_for_branch("phase-2.5"), "phase-2-5");
740 assert_eq!(sanitize_phase_for_branch("Phase 7"), "phase-7");
741 assert_eq!(sanitize_phase_for_branch("///"), "phase");
742 }
743
744 #[test]
745 fn parse_run_number_extracts_suffix() {
746 assert_eq!(parse_run_number("phase-2-run-001", "phase-2-run-"), Some(1));
747 assert_eq!(
748 parse_run_number("phase-2-run-1234", "phase-2-run-"),
749 Some(1234)
750 );
751 assert_eq!(parse_run_number("phase-2-run-aa1", "phase-2-run-"), None);
752 assert_eq!(parse_run_number("other-001", "phase-2-run-"), None);
753 }
754
755 #[test]
756 fn prepare_phase_worktree_increments_run_number() {
757 let Some(tmp) = init_repo() else {
758 return;
759 };
760
761 let first = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
762 let second = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
763
764 assert!(
765 first.branch.ends_with("001"),
766 "first branch: {}",
767 first.branch
768 );
769 assert!(
770 second.branch.ends_with("002"),
771 "second branch: {}",
772 second.branch
773 );
774 assert!(first.path.is_dir());
775 assert!(second.path.is_dir());
776
777 cleanup_worktree(tmp.path(), &first);
778 cleanup_worktree(tmp.path(), &second);
779 }
780
781 #[test]
782 fn finalize_keeps_unmerged_completed_worktree() {
783 let Some(tmp) = init_repo() else {
784 return;
785 };
786
787 let worktree = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
788 let decision = worktree.finalize(RunOutcome::Completed).unwrap();
789
790 assert_eq!(decision, CleanupDecision::KeptForReview);
791 assert!(worktree.path.exists());
792 assert!(branch_exists(tmp.path(), &worktree.branch).unwrap());
793
794 cleanup_worktree(tmp.path(), &worktree);
795 }
796
797 #[test]
798 fn finalize_keeps_failed_worktree() {
799 let Some(tmp) = init_repo() else {
800 return;
801 };
802
803 let worktree = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
804 let decision = worktree.finalize(RunOutcome::Failed).unwrap();
805
806 assert_eq!(decision, CleanupDecision::KeptForFailure);
807 assert!(worktree.path.exists());
808 assert!(branch_exists(tmp.path(), &worktree.branch).unwrap());
809
810 cleanup_worktree(tmp.path(), &worktree);
811 }
812
813 #[test]
814 fn finalize_cleans_when_merged() {
815 let Some(tmp) = init_repo() else {
816 return;
817 };
818
819 let worktree = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
820
821 fs::write(worktree.path.join("work.txt"), "done\n").unwrap();
822 git(&worktree.path, &["add", "work.txt"]);
823 git(&worktree.path, &["commit", "-q", "-m", "worktree change"]);
824
825 git(
826 tmp.path(),
827 &["merge", "--no-ff", "--no-edit", worktree.branch.as_str()],
828 );
829
830 let decision = worktree.finalize(RunOutcome::Completed).unwrap();
831 assert_eq!(decision, CleanupDecision::Cleaned);
832 assert!(!worktree.path.exists());
833 assert!(!branch_exists(tmp.path(), &worktree.branch).unwrap());
834 }
835
836 #[test]
837 fn resolve_phase_worktree_resumes_latest_existing_by_default() {
838 let Some(tmp) = init_repo() else {
839 return;
840 };
841
842 let first = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
843 let second = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
844
845 let (resolved, resumed) = resolve_phase_worktree(tmp.path(), "phase-2.5", false).unwrap();
846 assert!(
847 resumed,
848 "expected default behavior to resume existing worktree"
849 );
850 assert_eq!(
851 resolved.branch, second.branch,
852 "should resume latest run branch"
853 );
854 assert_eq!(resolved.path, second.path, "should resume latest run path");
855
856 cleanup_worktree(tmp.path(), &first);
857 cleanup_worktree(tmp.path(), &second);
858 }
859
860 #[test]
861 fn resolve_phase_worktree_force_new_creates_next_run() {
862 let Some(tmp) = init_repo() else {
863 return;
864 };
865
866 let first = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
867 let (resolved, resumed) = resolve_phase_worktree(tmp.path(), "phase-2.5", true).unwrap();
868
869 assert!(!resumed, "force-new should never resume prior worktree");
870 assert_ne!(resolved.branch, first.branch);
871 assert!(
872 resolved.branch.ends_with("002"),
873 "branch: {}",
874 resolved.branch
875 );
876
877 cleanup_worktree(tmp.path(), &first);
878 cleanup_worktree(tmp.path(), &resolved);
879 }
880
881 #[test]
882 fn resolve_phase_worktree_without_existing_creates_new() {
883 let Some(tmp) = init_repo() else {
884 return;
885 };
886
887 let (resolved, resumed) = resolve_phase_worktree(tmp.path(), "phase-2.5", false).unwrap();
888 assert!(!resumed);
889 assert!(
890 resolved.branch.ends_with("001"),
891 "branch: {}",
892 resolved.branch
893 );
894
895 cleanup_worktree(tmp.path(), &resolved);
896 }
897
898 #[test]
899 fn prepare_agent_worktrees_creates_layout_and_branches() {
900 let Some(tmp) = init_repo() else {
901 return;
902 };
903
904 let names = vec!["agent-1".to_string(), "agent-2".to_string()];
905 let worktrees = prepare_agent_worktrees(tmp.path(), "phase-4", &names, false).unwrap();
906
907 assert_eq!(worktrees.len(), 2);
908 assert_eq!(
910 worktrees[0].path.canonicalize().unwrap(),
911 tmp.path()
912 .join(".batty")
913 .join("worktrees")
914 .join("phase-4")
915 .join("agent-1")
916 .canonicalize()
917 .unwrap()
918 );
919 assert_eq!(worktrees[0].branch, "batty/phase-4/agent-1");
920 assert!(branch_exists(tmp.path(), "batty/phase-4/agent-1").unwrap());
921 assert!(branch_exists(tmp.path(), "batty/phase-4/agent-2").unwrap());
922
923 cleanup_agent_worktrees(tmp.path(), &worktrees);
924 }
925
926 #[test]
927 fn prepare_agent_worktrees_reuses_existing_agent_paths() {
928 let Some(tmp) = init_repo() else {
929 return;
930 };
931
932 let names = vec!["agent-1".to_string(), "agent-2".to_string()];
933 let first = prepare_agent_worktrees(tmp.path(), "phase-4", &names, false).unwrap();
934 let second = prepare_agent_worktrees(tmp.path(), "phase-4", &names, false).unwrap();
935
936 assert_eq!(first[0].path, second[0].path);
937 assert_eq!(first[1].path, second[1].path);
938 assert_eq!(first[0].branch, second[0].branch);
939 assert_eq!(first[1].branch, second[1].branch);
940
941 cleanup_agent_worktrees(tmp.path(), &first);
942 }
943
944 #[test]
945 fn prepare_agent_worktrees_rejects_duplicate_sanitized_names() {
946 let Some(tmp) = init_repo() else {
947 return;
948 };
949
950 let names = vec!["agent 1".to_string(), "agent-1".to_string()];
951 let err = prepare_agent_worktrees(tmp.path(), "phase-4", &names, false)
952 .unwrap_err()
953 .to_string();
954 assert!(err.contains("duplicate sanitized slug"));
955 }
956
957 #[test]
958 fn prepare_agent_worktrees_force_new_recreates_worktrees() {
959 let Some(tmp) = init_repo() else {
960 return;
961 };
962
963 let names = vec!["agent-1".to_string()];
964 let first = prepare_agent_worktrees(tmp.path(), "phase-4", &names, false).unwrap();
965
966 fs::write(first[0].path.join("agent.txt"), "agent-1\n").unwrap();
967 git(&first[0].path, &["add", "agent.txt"]);
968 git(&first[0].path, &["commit", "-q", "-m", "agent work"]);
969
970 let second = prepare_agent_worktrees(tmp.path(), "phase-4", &names, true).unwrap();
971 let listing = run_git(tmp.path(), ["branch", "--list", "batty/phase-4/agent-1"]).unwrap();
972 assert!(listing.status.success());
973 assert!(second[0].path.exists());
974
975 cleanup_agent_worktrees(tmp.path(), &second);
976 }
977
978 #[test]
979 fn sync_phase_board_copies_uncommitted_tasks_into_worktree() {
980 let Some(tmp) = init_repo() else {
981 return;
982 };
983
984 let kanban = tmp.path().join(".batty").join("kanban");
986 let phase_dir = kanban.join("my-phase").join("tasks");
987 fs::create_dir_all(&phase_dir).unwrap();
988 fs::write(phase_dir.join("001-old.md"), "old task\n").unwrap();
989 fs::write(
990 kanban.join("my-phase").join("config.yml"),
991 "version: 10\nnext_id: 2\n",
992 )
993 .unwrap();
994 git(tmp.path(), &["add", ".batty"]);
995 git(tmp.path(), &["commit", "-q", "-m", "add phase board"]);
996
997 let worktree = prepare_phase_worktree(tmp.path(), "my-phase").unwrap();
999 let wt_task = worktree
1000 .path
1001 .join(".batty")
1002 .join("kanban")
1003 .join("my-phase")
1004 .join("tasks")
1005 .join("001-old.md");
1006 assert!(wt_task.exists(), "worktree should have committed task");
1007
1008 fs::write(phase_dir.join("002-new.md"), "new task\n").unwrap();
1010
1011 sync_phase_board_to_worktree(tmp.path(), &worktree.path, "my-phase").unwrap();
1013
1014 let wt_tasks_dir = worktree
1016 .path
1017 .join(".batty")
1018 .join("kanban")
1019 .join("my-phase")
1020 .join("tasks");
1021 assert!(wt_tasks_dir.join("001-old.md").exists());
1022 assert!(
1023 wt_tasks_dir.join("002-new.md").exists(),
1024 "uncommitted task should be synced into worktree"
1025 );
1026
1027 let content = fs::read_to_string(wt_tasks_dir.join("002-new.md")).unwrap();
1029 assert_eq!(content, "new task\n");
1030
1031 cleanup_worktree(tmp.path(), &worktree);
1032 }
1033
1034 #[test]
1035 fn sync_phase_board_overwrites_stale_worktree_board() {
1036 let Some(tmp) = init_repo() else {
1037 return;
1038 };
1039
1040 let kanban = tmp.path().join(".batty").join("kanban");
1042 let phase_dir = kanban.join("my-phase").join("tasks");
1043 fs::create_dir_all(&phase_dir).unwrap();
1044 fs::write(phase_dir.join("001-old.md"), "original\n").unwrap();
1045 fs::write(
1046 kanban.join("my-phase").join("config.yml"),
1047 "version: 10\nnext_id: 2\n",
1048 )
1049 .unwrap();
1050 git(tmp.path(), &["add", ".batty"]);
1051 git(tmp.path(), &["commit", "-q", "-m", "add phase board"]);
1052
1053 let worktree = prepare_phase_worktree(tmp.path(), "my-phase").unwrap();
1054
1055 fs::write(phase_dir.join("001-old.md"), "rewritten\n").unwrap();
1057
1058 sync_phase_board_to_worktree(tmp.path(), &worktree.path, "my-phase").unwrap();
1059
1060 let wt_content = fs::read_to_string(
1061 worktree
1062 .path
1063 .join(".batty")
1064 .join("kanban")
1065 .join("my-phase")
1066 .join("tasks")
1067 .join("001-old.md"),
1068 )
1069 .unwrap();
1070 assert_eq!(
1071 wt_content, "rewritten\n",
1072 "worktree board should reflect source tree changes"
1073 );
1074
1075 cleanup_worktree(tmp.path(), &worktree);
1076 }
1077
1078 #[test]
1079 fn sync_phase_board_noop_when_source_missing() {
1080 let Some(tmp) = init_repo() else {
1081 return;
1082 };
1083
1084 let worktree = prepare_phase_worktree(tmp.path(), "nonexistent").unwrap();
1085
1086 sync_phase_board_to_worktree(tmp.path(), &worktree.path, "nonexistent").unwrap();
1088
1089 cleanup_worktree(tmp.path(), &worktree);
1090 }
1091
1092 #[test]
1093 fn branch_fully_merged_true_after_cherry_pick() {
1094 let Some(tmp) = init_repo() else {
1095 return;
1096 };
1097
1098 git(tmp.path(), &["checkout", "-b", "feature"]);
1100 fs::write(tmp.path().join("feature.txt"), "feature work\n").unwrap();
1101 git(tmp.path(), &["add", "feature.txt"]);
1102 git(tmp.path(), &["commit", "-q", "-m", "add feature"]);
1103
1104 git(tmp.path(), &["checkout", "main"]);
1106 git(tmp.path(), &["cherry-pick", "feature"]);
1107
1108 assert!(branch_fully_merged(tmp.path(), "feature", "main").unwrap());
1110 }
1111
1112 #[test]
1113 fn branch_fully_merged_false_with_unique_commits() {
1114 let Some(tmp) = init_repo() else {
1115 return;
1116 };
1117
1118 git(tmp.path(), &["checkout", "-b", "feature"]);
1120 fs::write(tmp.path().join("unique.txt"), "unique work\n").unwrap();
1121 git(tmp.path(), &["add", "unique.txt"]);
1122 git(tmp.path(), &["commit", "-q", "-m", "unique commit"]);
1123 git(tmp.path(), &["checkout", "main"]);
1124
1125 assert!(!branch_fully_merged(tmp.path(), "feature", "main").unwrap());
1126 }
1127
1128 #[test]
1129 fn branch_fully_merged_true_when_same_tip() {
1130 let Some(tmp) = init_repo() else {
1131 return;
1132 };
1133
1134 git(tmp.path(), &["checkout", "-b", "feature"]);
1136 git(tmp.path(), &["checkout", "main"]);
1137
1138 assert!(branch_fully_merged(tmp.path(), "feature", "main").unwrap());
1139 }
1140
1141 #[test]
1142 fn branch_fully_merged_false_partial_merge() {
1143 let Some(tmp) = init_repo() else {
1144 return;
1145 };
1146
1147 git(tmp.path(), &["checkout", "-b", "feature"]);
1149 fs::write(tmp.path().join("a.txt"), "a\n").unwrap();
1150 git(tmp.path(), &["add", "a.txt"]);
1151 git(tmp.path(), &["commit", "-q", "-m", "first"]);
1152
1153 fs::write(tmp.path().join("b.txt"), "b\n").unwrap();
1154 git(tmp.path(), &["add", "b.txt"]);
1155 git(tmp.path(), &["commit", "-q", "-m", "second"]);
1156
1157 git(tmp.path(), &["checkout", "main"]);
1159 git(tmp.path(), &["cherry-pick", "feature~1"]);
1160
1161 assert!(!branch_fully_merged(tmp.path(), "feature", "main").unwrap());
1163 }
1164
1165 #[test]
1166 fn git_current_branch_returns_branch_name() {
1167 let Some(tmp) = init_repo() else {
1168 return;
1169 };
1170
1171 let branch = git_current_branch(tmp.path()).unwrap();
1173 assert!(!branch.is_empty(), "should return a non-empty branch name");
1175 }
1176
1177 #[test]
1178 fn reset_worktree_to_base_switches_branch() {
1179 let Some(tmp) = init_repo() else {
1180 return;
1181 };
1182
1183 let wt_path = tmp.path().join("wt");
1185 git(
1186 tmp.path(),
1187 &[
1188 "worktree",
1189 "add",
1190 "-b",
1191 "feature-reset",
1192 wt_path.to_str().unwrap(),
1193 "main",
1194 ],
1195 );
1196 fs::write(wt_path.join("work.txt"), "work\n").unwrap();
1197 git(&wt_path, &["add", "work.txt"]);
1198 git(&wt_path, &["commit", "-q", "-m", "work on feature"]);
1199
1200 let branch_before = git_current_branch(&wt_path).unwrap();
1202 assert_eq!(branch_before, "feature-reset");
1203
1204 git(tmp.path(), &["branch", "eng-main/test-eng"]);
1206
1207 reset_worktree_to_base(&wt_path, "eng-main/test-eng").unwrap();
1208
1209 let branch_after = git_current_branch(&wt_path).unwrap();
1210 assert_eq!(branch_after, "eng-main/test-eng");
1211
1212 let _ = run_git(
1214 tmp.path(),
1215 ["worktree", "remove", "--force", wt_path.to_str().unwrap()],
1216 );
1217 let _ = run_git(tmp.path(), ["branch", "-D", "feature-reset"]);
1218 let _ = run_git(tmp.path(), ["branch", "-D", "eng-main/test-eng"]);
1219 }
1220}