Skip to main content

batty_cli/
worktree.rs

1//! Git worktree lifecycle for isolated phase runs.
2//!
3//! Each `batty work <phase>` run gets a dedicated branch/worktree:
4//! `<phase-slug>-run-<NNN>`.
5//! The executor runs in that worktree. Cleanup is merge-aware:
6//! - merged runs are removed (worktree + branch)
7//! - rejected/failed/unmerged runs are retained for inspection
8#![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
74/// Create an isolated git worktree for a phase run.
75pub 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
130/// Resolve the phase worktree for a run.
131///
132/// Behavior:
133/// - If `force_new` is false, resume the latest existing `<phase>-run-###` worktree if found.
134/// - Otherwise (or if none exists), create a new worktree.
135///
136/// Returns `(worktree, resumed_existing)`.
137pub 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
149/// Prepare (or reuse) one worktree per parallel agent slot for a phase.
150///
151/// Layout:
152/// - path: `.batty/worktrees/<phase>/<agent>/`
153/// - branch: `batty/<phase-slug>/<agent-slug>`
154pub 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
540/// Sync the phase board from the source tree into the worktree.
541///
542/// Worktrees are created from committed state, so any uncommitted kanban
543/// changes (new tasks, reworked boards, etc.) would be lost. This copies
544/// the phase directory from `source_kanban_root/<phase>/` into
545/// `worktree_kanban_root/<phase>/`, overwriting whatever git checked out.
546///
547/// Only syncs when the source directory exists and differs from the
548/// worktree (i.e., the source tree has uncommitted kanban changes).
549pub 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    // Remove stale destination and copy fresh.
563    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
589/// Check if all commits on `branch` since diverging from `base` are already
590/// present on `base` (e.g., via cherry-pick).
591///
592/// Uses `git cherry <base> <branch>` — lines starting with `-` are already on
593/// base. If ALL lines start with `-` (or output is empty), the branch is fully
594/// merged.
595pub 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        // Lines starting with '+' are commits NOT on base.
613        if trimmed.starts_with('+') {
614            return Ok(false);
615        }
616    }
617    Ok(true)
618}
619
620/// Get the current branch name for a repository/worktree path.
621pub 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
640/// Reset a worktree to point at its base branch. Used to clean up after a
641/// cherry-pick merge has made the task branch redundant.
642pub 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        // Compare canonicalized paths to handle macOS /var vs /private/var symlink
909        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        // Create a committed phase board with one task.
985        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        // Create a worktree — it will have the committed (old) board.
998        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        // Now add a new task to the source tree (uncommitted).
1009        fs::write(phase_dir.join("002-new.md"), "new task\n").unwrap();
1010
1011        // Sync.
1012        sync_phase_board_to_worktree(tmp.path(), &worktree.path, "my-phase").unwrap();
1013
1014        // Worktree should now have both tasks.
1015        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        // The new file content should match.
1028        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        // Create a committed phase board.
1041        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        // Rewrite the source task (uncommitted change).
1056        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        // Should not error when source phase dir doesn't exist.
1087        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        // Create a feature branch with a commit.
1099        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        // Go back to main and cherry-pick the commit.
1105        git(tmp.path(), &["checkout", "main"]);
1106        git(tmp.path(), &["cherry-pick", "feature"]);
1107
1108        // Now all commits on feature are present on main.
1109        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        // Create a feature branch with a commit NOT on main.
1119        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        // Feature branch at the same commit as main — no unique commits.
1135        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        // Create a feature branch with two commits.
1148        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        // Cherry-pick only the first commit onto main.
1158        git(tmp.path(), &["checkout", "main"]);
1159        git(tmp.path(), &["cherry-pick", "feature~1"]);
1160
1161        // One commit is still unique — should be false.
1162        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        // Default branch after init_repo is "main" (or whatever git defaults to).
1172        let branch = git_current_branch(tmp.path()).unwrap();
1173        // The init_repo doesn't specify -b, so branch could be "main" or "master".
1174        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        // Create a worktree on a feature branch.
1184        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        // Verify we're on the feature branch.
1201        let branch_before = git_current_branch(&wt_path).unwrap();
1202        assert_eq!(branch_before, "feature-reset");
1203
1204        // Create a base branch for the worktree to reset to.
1205        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        // Cleanup
1213        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}